Skip to content

Minimal shared dynamic states by hoisting React Hooks up to React Contexts.

License

Notifications You must be signed in to change notification settings

theminstack/factor

Repository files navigation

MinStack Factor

Minimal shared dynamic states by hoisting React Hooks up to React Contexts.

build codecov

Goals and Features:

  • Improve efficiency over custom dynamic contexts or simple hoisting.
  • Compose state and behavior using any React hook.
  • Leverage React hook testing tools.
  • Reference parent state when nested.
  • Change behavior based on idle or active status.
  • Update when new consumers are mounted.

Table of Contents:

Get Started

Let's say we have a useAlerts hook. This is something that might be used to show banners at the top of a page, or with an inbox icon in the app header.

const { alerts, addAlert, removeAlert } = useAlerts({ expire: 5000 });

As an app grows, the places that generate alerts will become more widely separated from the alerts display, resulting in more "property drilling". React Factor makes it easy to hoist the alerts hook and then access the state in children, without having to pass down properties through parent components.

Create a factor from the useAlerts hook.

import { createFactor } from '@minstack/factor';

const AlertsFactor = createFactor(useAlerts);

The returned factor is a context provider. The useAlerts hook options are now the props of the AlertsFactor component.

Note: Factor hooks may only accept one props-like object argument.

<AlertsFactor expire={5000}>{children}</AlertsFactor>

The factor state (ie. the value returned by useAlerts) can be accessed with the useFactor hook in any child component. When transitioning from hook to factor, hook calls can be swapped 1:1 with useFactor calls.

// Before
const { alerts, addAlert, removeAlert } = useAlerts({ expire: 5000 });
// After
const { alerts, addAlert, removeAlert } = useFactor(AlertsFactor);

Select Single Values

The useFactor hook also accepts a second selector function argument. The hook will only trigger a re-render when the selected value changes.

// Only rerender when "value.addAlert" is updated.
const addAlert = useFactor(AlertsFactor, (value) => {
  return value.addAlert;
});

Ideally, selectors should be pure functions with implementations that do not change during the lifetime of a component. A literal arrow function without any outer scope references is recommended.

However, if you need to reference values from the component scope, the useFactor hook accepts a third dependency array argument (much like React's useCallback hook) which will trigger reselection when dependencies change.

const { alertId } = props;
// Rerenders when the alertId or the selected alert are updated.
const alert = useFactor(AlertsFactor, (value) => value.alerts[alertId], [alertId]);

Select Tuples

Tuples (multiple values) can be selected from a factor by providing an array or object map of selector functions. When this pattern is used, only tuple values are compared (not the containing array or object reference) when deciding if an update is required.

// Array Tuple
const [addAlert, removeAlert] = useFactor(AlertsFactor, [(value) => value.addAlert, (value) => value.removeAlert]);

// Object Tuple
const { addAlert, removeAlert } = useFactor(AlertsFactor, {
  addAlert: (value) => value.addAlert,
  removeAlert: (value) => value.removeAlert,
});

Use Optional Factors

The useFactor(Factor) hook throws an error if it does not have a Factor parent. If a component doesn't need the factor to work correctly, the useOptionalFactor hook can be used to return undefined instead of throwing.

const valueOrUndefined = useOptionalFactor(AlertsFactor);

When an optional factor hook has no factor parent, the selector is still called with an undefined value. Therefore, selectors can be used to provide default values when used with optional factors.

const alerts = useOptionalFactor(AlertsFactor, (value) => {
  return value ? value.alerts : [];
});

Reference Parent State

A factor hook can reference its own factor, allowing it to inherit the state from a parent of the same factor type.

A simple example would be counting how many factor parents a component has.

const NestingFactor = createFactor(() => {
  return useOptionalFactor(NestingFactor, (value = 0) => value + 1);
});

const NestingCount = () => {
  const count = useOptionalFactor(NestingFactor, (value = 0) => value);
  return <div>{count}</div>;
};

render(
  <NestingFactor>
    <NestingFactor>
      <NestingCount />
    </NestingFactor>
  </NestingFactor>,
);

// Rendered: <div>2</div>

Use Consumer Information

A factor can react to consumers (ie. components which use the factor) in the following ways.

  • Change behavior based on whether or not consumers exist.
  • Take action when a consumer is mounted.

Handle Factor Idle or Active

The useFactorStatus hook returns "idle" when there are no consumers, or "active" when consumers exist.

const AlertsFactor = createFactor((options: Options) => {
  const factorStatus = useFactorStatus();

  return useAlerts({
    ...options,
    // Provide a default value for the hook "enabled" option.
    enabled: options.enabled ?? factorStatus === 'active',
  });
});

The useFactorStatus hook also returns "active" when used outside of a factor. A hook used outside of a factor is its own consumer, and therefore always active.

Handle Factor Mounted

The useFactorMountEffect performs a side-effect when a consumer is mounted.

Note: Detecting individual consumer unmounts is not supported. See useFactorStatus to detect no consumers.

const AlertsFactor = createFactor((options: Options) => {
  const value = useAlerts(options);

  useFactorMountEffect(() => {
    value.update();
  });

  return value;
});

When used outside of a factor, the effect will be run once on mount. A hook used outside of a factor is its own consumer, and therefore a consumer is mounted when the hook is mounted.

Compare Alternatives

The shared state management problem has been solved and re-resolved many times. However, it still seems like a major pain point when developing an application.

I can't reasonably go over all the alternatives. So, here are some that are popular and demonstrate some of the difficulties involved.

React Context

For constant values, a simple custom context is probably the best option. But, for dynamic values, there are usually two solutions.

  1. Create a class and provide instances through a context provider.
  2. Create a stateful custom component which renders a context provider.

Solution 1 is the most common and efficient (eg. the Redux store). But, the class will need its own lifecycle management and subscription mechanism to let consumers know when the class state changes. It lives outside of the React tree and therefore can't use hooks.

Solution 2 is easier to implement, but will cause extra renders starting at the provider (instead of the consumers) every time the context state changes.

A factor is a combination of these two solutions providing the best of both worlds. It can leverage react hooks, but moves them to a leaf/worker component which doesn't have any children to rerender. What is provided through context is a subscribable class instance which takes care of efficiently notifying consumers about updates.

Redux

Redux does solve the same problems, but it comes bundled with two other things that might not be needed or wanted.

  1. The Flux pattern.
  2. Centralized state.

The Flux pattern is neat. But, YAGNI.

Centralized state is another way to say globals, which are an attractive nuisance.

Others

These are less popular (but still common) choices, with a scope similar to this library.

The basic requirement for any shared state solution, is that it should be more efficient than using a vanilla React context. But, Constate triggers rerenders at the provider whenever the state changes. This is extremely puzzling given its recent popularity. Either I'm missing something, or very few people are looking at the code and using it in places where performance problems would be noticed. It has selectors, but these the don't prevent any rerenders, and even potentially increased the overhead by generating extra stacked contexts. Please let me know if I'm wrong about this.

The Recoil, Jotai, Zustand, and react-hooks-global-state state behavior cannot be defined using hooks. They can be used by hooks, but cannot themselves use hooks in the atom or context definitions. This means that you can't easily uplift or reuse any of your existing hooks. It adds knowledge overhead for the patterns specific to a library. And, the React hooks testing infrastructure can't be leveraged directly.

The react-hooks-global-state library does not use the React context system at all. Instead, it creates new hooks which are tied to a global state. This is simple, but makes scoping difficult, if not impossible. It can also cause problems if two versions of the library are ever used together.

And finally, the use-context-selector library uses unstable internals, and also uses render side-effects incorrectly. It's academically interesting as a proposal proof-of-concept. However, it will break in future versions of React, potentially even in minor or patch version changes.