@ricokahler/stable-hooks

bundlephobia https://github.com/ricokahler/stable-hooks/actions codecov

⚠️ This library is well-tested but the README/docs still needs work. A 1.0 should be around the corner.

hooks that wrap unstable values for more control over incoming hook dependencies

Installation

npm i --save @ricokahler/stable-hooks

Motivation

In complex React components, it quickly becomes challenging to control how incoming values affect your downstream hooks.

For example, the following <Dialog /> component has a bug that causes it to re-run the onOpen or onClose callbacks if the consumer does not wrap the callbacks in useCallback.

import { useState, useEffect } from 'react';

function Dialog({ onOpen, onClose }) {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    if (open) onOpen();
    else onClose();
  }, [open, onOpen, onClose]);

  return (
    <>
      <button onClick={() => setOpen(!open)}>Toggle Dialog</button>

      <dialog open={open}>
        <p>Greetings, one and all!</p>
      </dialog>
    </>
  );
}

export default function App() {
  const [clicks, setClicks] = useState(0);

  return (
    <>
      <button onClick={() => setClicks(clicks + 1)}>Clicks {clicks}</button>
      <Dialog
        onOpen={() => console.log('Dialog was opened!')}
        onClose={() => console.log('Dialog was closed!')}
      />
    </>
  );
}

bug demo

stable-hooks are hooks you can use to wrap unstable values for more control over incoming hook dependencies.

Usage

useStableGetter

implementation | tests

Wraps incoming values in a stable getter function that returns the latest value.

Useful tool for signifying a value should not be considered as a reactive dependency.

When this getter is invoked, it pulls the latest value from a hidden ref. This ref is synced with the current inside of a useLayoutEffect so that it runs before other useEffects.

import { useState, useEffect } from 'react';
import { useStableGetter } from '@ricokahler/stable-hooks';

function Dialog(props) {
  const [open, setOpen] = useState(false);

  const getOnOpen = useStableGetter(props.onOpen);
  const getOnClose = useStableGetter(props.onClose);

  useEffect(() => {
    const onOpen = getOnOpen();
    const onClose = getOnClose();
    if (open) onOpen();
    else onClose();
  }, [open, getOnOpen, getOnClose]);

  return (
    <>
      <button onClick={() => setOpen(!open)}>Toggle Dialog</button>

      <dialog open={open}>
        <p>Greetings, one and all!</p>
      </dialog>
    </>
  );
}

useStableCallback

implementation | tests

Returns a stable callback that does not change between re-renders.

The implementation uses useStableGetter to get latest version of the callback (and the values closed within it) so values are not stale between different invocations.

import { useState, useEffect } from 'react';
import { useStableCallback } from '@ricokahler/stable-hooks';

function Dialog(props) {
  const [open, setOpen] = useState(false);

  const onOpen = useStableCallback(props.onOpen);
  const onClose = useStableCallback(props.onClose);

  useEffect(() => {
    if (open) onOpen();
    else onClose();
  }, [open, onOpen, onClose]);

  return (
    <>
      <button onClick={() => setOpen(!open)}>Toggle Dialog</button>

      <dialog open={open}>
        <p>Greetings, one and all!</p>
      </dialog>
    </>
  );
}

useStableValue

implementation | tests

Given an unstable value, useStableValue hashes the incoming value against a hashFn (by default, this is JSON.stringify) and if the hash is unchanged, the previous value will be returned.

Useful for defensively programming against unstable objects coming from props.

The implementation runs the value through the provided hash function and the result of that hash function is used as the only dependency in a useMemo call. See the implementation here.

function Example(props) {
  const style = useStableValue(props.style);

  useEffect(() => {
    // do something only when the _contents_ of
    // the style object changes
  }, [style]);

  return <>{/* ... */}</>;
}