Pixelry

React Scopes

State management continues to be a challenge for scalable application development. Simple approaches to state management often cause excessive rerenders that can cripple application performance. Large singleton immutable stores add significant complexity to applications and often cause unnecessary state changes.

React Scopes provide simple hooks that allow for high performance localized state sharing while also enabling composition of larger scale stores that minimize rerenders on state changes.

React Scopes solve for the same problems as JavaScript Signals, but in a React first approach. React Scopes natively participate in React features such as concurrency, snap shotting, and hot module reloading.

Get started here.

Performance Features

  • Scope objects are unchanging wrappers for state access. As such passing a scope via props or context will not cause rerenders when the contained state is changed.
  • Components can individually call the use hook on the scope to opt into rerenders on state changes.
  • When hooking a scope an optional reducer function can be passed that will only rerender when the resulting value is changed. For example you can use a scope of a number and reduce it to a boolean nonZero and only renderer when the non zero state changes.
  • Scopes provide a get accessor to retrieve the current state value without hooking the state. Calling the accessor inside of other hooks and callbacks avoids the need of passing the state value as a hook dependency, avoiding reevaluating the hooks on state changes.
  • Scopes are composable which avoids large singleton immutable objects. For example, the Array Scope and Record Scope can be used together to create a list of objects that avoid rerenders of all objects when any one object is added, deleted, or changed.

Simple Localized State Sharing

In this example we build a change password form. There are four components involved:

  • The main component owns the state and composes the UI.
  • The password component.
  • The reenter password component.
  • The submit component with realtime validation of password rules.

Example

New Password
Reenter Password
Passwords is 8 characters or longer
Contains uppercase, lowercase, and numbers
Passwords Match

The main component creates the scope. Since it does not use the scope, it will not rerender when the state changes. You can confirm this by opening the dev tools and seeing the console log item ChangePassword rendered only appears once even when editing the form.

type PasswordData = {
  password: string;
  reenter: string;
};

function ChangePassword() {
  console.log('ChangePassword rendered');

  const scope = useObjectScope<PasswordData>({ password: '', reenter: '' });

  return (
    <div
      className={clsx(
        'w-96', // size
        'flex flex-col gap-2', // layout
        'bg-base-50 dark:bg-base-800', // color
        'p-2', // spacing
        'border rounded dark:border-base-500', // border
      )}
    >
      <PasswordField scope={scope}></PasswordField>
      <ReenterField scope={scope}></ReenterField>
      <SubmitValidation scope={scope}></SubmitValidation>
    </div>
  );
}

The password component is an unmanaged input which sets the scope state on change. Since it does not use the scope state it never rerenders on state changes. We can do this optimization since we know that the password is never changed by another component. It also clears the reenter value.

Note that the handleChange callback has a dependency on the scope and not the value.

function PasswordField(props: { scope: IObjectScope<PasswordData> }) {
  console.log('PasswordField rendered');

  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      props.scope.set({
        password: event.target.value,
        reenter: '',
      });
    },
    [props.scope],
  );

  return (
    <>
      <div>New Password</div>
      <input
        className={clsx(
          'w-full', // size
          'px-2', // spacing
          'bg-white dark:bg-black', // color
          'border dark:border-base-500', // border
        )}
        type="password"
        onChange={handleChange}
      ></input>
    </>
  );
}

The reenter password component is a managed input since the reenter state can change outside of this component. As such it explicitly uses the scope and reduces to just the value of the reenter member. Thus is will rerender anytime the reenter value changes.

Using the dev tools you can see ReenterField rendered as you type in the reenter field. Note that if the reenter value is blank it does not rerender as you change the password since the reducer function runs as part of the test to determine if the component is invalid.

function ReenterField(props: { scope: IObjectScope<PasswordData> }) {
  console.log('ReenterField rendered');

  const reenter = props.scope.use(value => value.reenter);

  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      props.scope.set({
        ...props.scope.get(),
        reenter: event.target.value,
      });
    },
    [props.scope],
  );

  return (
    <>
      <div>Reenter Password</div>
      <input
        className={clsx(
          'w-full', // size
          'px-2', // spacing
          'bg-white dark:bg-black', // color
          'border dark:border-base-500', // border
        )}
        type="password"
        value={reenter}
        onChange={handleChange}
      ></input>
    </>
  );
}

Lastly we have the validation text and submit button component. It evaluates the validity inside the reducer functions, thus only rerenders when the validity changes rather than on every password change.

function SubmitValidation(props: { scope: IObjectScope<PasswordData> }) {
  console.log('PasswordValidation rendered');

  const length = props.scope.use(value => value.password.length >= 8);

  const match = props.scope.use(
    value => value.password.length > 0 && value.password == value.reenter,
  );

  const symbols = props.scope.use(
    value =>
      /d/.test(value.password) &&
      /[A-Z]+/.test(value.password) &&
      /[a-z]+/.test(value.password),
  );

  return (
    <>
      <div className={length ? 'text-green-500' : 'text-red-500'}>
        Passwords is 8 characters or longer
      </div>
      <div className={symbols ? 'text-green-500' : 'text-red-500'}>
        Contains uppercase, lowercase, and numbers
      </div>
      <div className={match ? 'text-green-500' : 'text-red-500'}>
        Passwords Match
      </div>
      <button
        disabled={!(length && symbols && match)}
        className={clsx(
          'disabled:text-gray-300 dark:disabled:text-gray-500', // colors
          'border dark:border-base-500', // border
        )}
      >
        Submit
      </button>
    </>
  );
}

This simple example shows how scopes allow each component to optimize their use of state without the parent component needing to optimize on behalf of the child. In larger component trees these techniques enable significant performance improvements over simple hooks with prop drilling.

Stay tuned for future articles where we look at how scope composition can be used to create high performance stores of complex state.

Note on Styling

If the CSS styling in the above examples are new to you, you can learn more in our Tailwind CSS articles here.