State in Virtual-DOM

One of the fundamental pieces of working with Virtual-DOM libraries is state, because the eventual view is a function of state. We have several ways to track state, we can do it in the library itself with hooks, setState, observables, ... or outside of the library.

In this post we will explore the differences between both in the shape of a form-view, we want to efficiently track state for our form.

Context

In Preact we'll often distribute state across the whole tree by means of the context API, this is an efficient and clean method of just hooking into a piece of state coming from a parent, however context lacks granularity let's take a look.

import { createContext } from 'preact';
import { useState, useContext } from 'preact/hooks';

const FormContext = createContext();

const useField = (name) => {
  const form = useContext(FormContext)

  return [
    form.values[name],
    (e) => {
      form.setValues({
        ...form.values,
        [name]: e.currentTarget.value,
      })
    }
  ]
}

const Input = (props) => {
  const [value, onInput] = useField(props.name)
  return <input value={value} onInput={onInput} />
}

const Form = () => {
  const form = useContext(FormContext)

  const onSubmit = (e) => {
    e.preventDefault();
    console.log(form.values)
  }

  return (
    <form onSubmit={onSubmit}>
      <Input name='firstName' />
      <Input name='lastName' />
      <Input name='country' />
      <Input name='website' />
    </form>
  )
}

const FormProvider = (props) => {
  const [values, setValues] = useState(props.initialValues)

  return (
    <FormContext.Provider
      value={{
        values,
        setValues,
      }}
      children={props.children}
    />
  )
}

const App = () => {
  return (
    <FormProvider
      initialValues={{
        firstName: 'Jovi',
        lastName: 'De  Croock',
        country: 'Belgium',
        website: 'https://www.jovidecroock.com/',
      }}
    >
      <Form />
    </FormProvider>
  )
}

Contact details

rerenders: 0

rerenders: 0

rerenders: 0

rerenders: 0

You will see that it doesn't matter what form-field you touch, it will re-render all of them. This happens because context has no knowledge of what component is watching what field, ... so even if we would wrap everything with memo() or leverage shouldComponentUpdate we would still try to diff it up until that boundary. This means that every subscriber gets checked when something changes, there have been proposals to resolve this with context-selector but this is not present currently.

We use the optimization of Strict-Equality, read about it in the previous blog post, on the FormProvider so we aren't causing top-down renders when state changes.

State outside of the Virtual-DOM

The theory behind externally managing the state is that we allow for granular updates, components can subscribe to the slices of state they want rather than subscribing to the full context. Don't get me wrong here, we will still be using context but the value of it won't change, the value will be a stable entity that allows components to subscribe to state, change it and be notified about changes.

For the sake of simplicity I kept this example fairly in line with the above, in the example you'll see that the external state-manager still needs a way to couple back to the Virtual DOM library so we need to give it a way to notify that changes are present.

import { createContext } from 'preact';
import { useContext, useReducer, useEffect } from 'preact/hooks';

class FormState {
  values;
  listeners = {};

  constructor(values) {
    this.values = values;
  }

  onChange(name, value) {
    this.values[name] = value;
    this.listeners[name].forEach(cb => { cb(); })
  }

  register(name, cb) {
    const listenersForField = this.listeners[name]
    if (!listenersForField) {
      this.listeners[name] = [cb]
    } else {
      listenersForField.push(cb)
    }
  }

  unregister(name, cb) {
    const listenersForField = this.listeners[name]
    this.listeners[name] = listenersForField.filter(x => x !== cb)
  }
}

const FormContext = createContext();

const useField = (name) => {
  // This function is used to force a re-render, this way FormState
  // can notify our runtime of a change
  const [, rerender] = useReducer((x) => x + 1, 0)
  const form = useContext(FormContext)

  useEffect(() => {
    form.register(name, rerender)
    return () => {
      form.unregister(name, rerender)
    }
  }, [])

  return [
    form.values[name],
    (e) => {
      form.onChange(name, e.currentTarget.value)
    }
  ]
}

const Input = (props) => {
  const [value, onInput] = useField(props.name)
  return <input value={value} onInput={onInput} />
}

const Form = () => {
  const form = useContext(FormContext)

  const onSubmit = (e) => {
    e.preventDefault();
    console.log(form.values)
  }

  return (
    <form onSubmit={onSubmit}>
      <Input name='firstName' />
      <Input name='lastName' />
      <Input name='country' />
      <Input name='website' />
    </form>
  )
}

const FormProvider = (props) => (
  <FormContext.Provider
    value={new FormState(props.initialValues)}
    children={props.children}
  />
)

const App = () => (
  <FormProvider
    initialValues={{
      firstName: 'Jovi',
      lastName: 'De  Croock',
      country: 'Belgium',
      website: 'https://www.jovidecroock.com/',
    }}
  >
    <Form />
  </FormProvider>
)

Contact details

rerenders: 0

rerenders: 0

rerenders: 0

rerenders: 0

As you can see the only difference between the two methods is the place where we store our state, in this case outside of the VDOM library, however we do leverage context to access it everywhere. The amount of re-renders can show us that this is a performance gain, on a small scale this won't be felt, however it can also help you reduce the amount of things to check when effects/... re-run so in my opinion it's a pretty good way to go about things.

This method is used by libraries like Redux, urql and many more to reduce the amount of renders that occur when state changes. Imagine changing a piece of state and the whole app having to recalculate!

Concluding

The latter optimization is one that library authors will often do for you, so next time before you go implementing Redux/... yourself consider what they are doing for you. Managing state with context can be a good solution but it can sometimes impose some performance constraints as demonstrated in the above.

Hope this gave you some insights.