Skip to content

Studying and understanding state with dependency tracking. A technique used in libraries like SWR.

License

Notifications You must be signed in to change notification settings

icyJoseph/state-with-deps

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

14 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

State Dependency Tracking

Inside libraries like swr lies a very interesting construct.

A state hook that can keep track of which parts of the state are being used.

It does this to render the consumer component, only when the pieces of state it consumes change, otherwise, it ignores the change.

The swr code summarizes it better than I can:

If a state property (data, error or isValidating) is accessed by the render function, we mark the property as a dependency so if it is updated again in the future, we trigger a rerender.

This is also known as dependency-tracking.

Background

When using React hooks, we want updates to propagate.

const [count, setCount] = useState(0);

For example a call to setCount updates the count variable, and we expect the component containing this hook to render.

Let's now complicate things a little. Say we have a hook that returns a a list of things, and also the length of the data list.

const useData = (label) => {
  const [data, setData] = useState([]);

  useEffect(() => {
    /* Do something to setData as a function of label */
    /* For example, make a network request, read from localStorage, etc. */
  }, [label, setData]);

  return { length: data.length, data };
};

So far so good. Data is a list, and length, represents the amount of elements in the list.

Now we have an interesting situation, a component using length from useData, renders when data changes, even if the length is still the same.

Let's decouple this through an additional hook:

const useDataLength = (label) => {
  const { length } = useData(label);
  return length;
};

You might think that because length is a number, React can bail out on rendering the consumer if the length is the same between renders, but that's not the case.

import { useEffect, useState } from "react";

const useData = () => {
  const [data, setData] = useState([]);

  useEffect(() => {
    const timer = setTimeout(() => {
      return setData([]);
    }, 1000);

    return () => clearTimeout(timer);
  }, []);

  return data;
};

const useLength = () => {
  const length = useData().length;

  return length;
};

export default function App() {
  const length = useLength();

  console.count("App: " + length);
  return <div>Hello World</div>;
}

With React's Strict Mode on, this counts four times App: 0. With Strict Mode off, it counts two times.

The point here is that data changes inside the setTimeout to a different list, that just happens to have the same length.

To bail out from the update, we need to return the same reference, for example by doing prev => prev on setData.

useEffect(() => {
  const timer = setTimeout(() => {
    return setData((prev) => prev);
  }, 1000);

  return () => clearTimeout(timer);
}, []);

With this modification, and React's Strict Mode on, this counts two times App: 0, and with Strict Mode off, it counts once.

You might even think that one could fix this, by wrapping length inside an object, and then wrap that with useMemo, like so:

const useLength = (label: string) => {
  const { length } = useData(label);

  return useMemo(
    () => ({
      length
    }),
    [length]
  );
};

But the issue here is that useData would still be contained within who ever is calling this hook, and that'll make those render.

Empty objects as default values and React rendering

The above often happens in another less obvious way. That is when we use de-structuring default values. In the snippet below, everytime we toggle, the data reference is a different empty array, so the effect is triggered.

Do you dare to uncomment the call to toggle inside useEffect

const useData = () => {
  return { data: undefined };
};

function App() {
  const { data = [] } = useData();

  useEffect(() => {
    console.log("useEffect", data);
    // do you dare to uncomment
    // toggle();
  }, [data]);

  const [, toggle] = useReducer((x) => !x, false);

  return <button onClick={toggle}>Toggle</button>;
}

Specification

In the situation above, how can we create an API, where hook consumers have the choice to read, the length of the data, without rendering again if the data changes, but the length is the same?

We want a hook useData which returns, length, and data.

const useData = () => {
  /* What do we do here? */
  return { length, data };
};

const App = () => {
  const { length } = useData();

  console.count("App render");
  return <div>Hello World</div>;
};

And when data changes, if it has the same length, we should not add one more to the App render count.

Test case

Since this behavior is kind of counter intuitive, at least for me, we might fool ourselves thinking we have a solution. That's why it is best to seal the desired behavior behind a unit test, and make sure we can write code that passes the test.

This repository contains the test below. You can find it here: src/__tests__/index.test.tsx.

import { SyntheticEvent, useState, useRef } from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { useData, useDataWithDepsTracking } from "../useData";

const App = () => {
  const [label, setLabel] = useState("");
  const { length } = useData(label);
  const ref = useRef<HTMLInputElement>(null);

  const onSubmit = (e: SyntheticEvent) => {
    e.preventDefault();

    if (!ref.current) return;

    setLabel(ref.current.value);
  };

  console.log({ length });
  console.count("Render App");

  return (
    <form onSubmit={onSubmit}>
      <input type="text" placeholder="Enter your string" ref={ref} />
      <button type="submit">submit</button>
    </form>
  );
};

const originalCount = console.count;
const spyCount = jest.fn();

beforeAll(() => {
  console.count = spyCount;
});

afterAll(() => {
  console.count = originalCount;
});

test("Renders only if length changes", () => {
  render(<App />);

  // one render
  expect(spyCount).toHaveBeenCalledTimes(1);

  fireEvent.change(screen.getByPlaceholderText("Enter your string"), {
    target: { value: "12345" }
  });

  fireEvent.click(screen.getByText("submit"));

  // the submission changes label + length recalculation
  expect(spyCount).toHaveBeenCalledTimes(3);

  fireEvent.change(screen.getByPlaceholderText("Enter your string"), {
    target: { value: "54321" }
  });

  fireEvent.click(screen.getByText("submit"));

  // the submission changes label
  // since the length of data is the same, no additional render should happen
  expect(spyCount).toHaveBeenCalledTimes(4);
});

And if we implement useData like this:

import { useEffect, useState } from "react";

export const useData = (label: string) => {
  const [data, setData] = useState(label.split(""));

  useEffect(() => {
    setData(label.split(""));
  }, [label, setData]);

  return {
    length: data.length,
    data
  };
};

Then we'll have a failing test:

● Renders only if length changes

    expect(jest.fn()).toHaveBeenCalledTimes(expected)

    Expected number of calls: 1
    Received number of calls: 2

      42 |
      43 |   // one render
    > 44 |   expect(spyCount).toHaveBeenCalledTimes(1);
         |                    ^
      45 |
      46 |   fireEvent.change(screen.getByPlaceholderText("Enter your string"), {
      47 |     target: { value: "12345" }

Notice that we don't even get to modify the input field, and the test already failed.

I've also included useLength in this repository, so you can try it and see it fail.

Anyway, let's write code to make the test pass!

Implementation

We'll need a special version of useState. Let's call it useStateWithDeps.

The useStateWithDeps hook needs a signature that extends useState's.

Plain and simple, we need to track which parts of the state are used. How can we do this in vanilla JavaScript?

let obj = (bar) => {
  const access = new Map();
  return {
    get foo() {
      access.set("foo", true);
      return bar;
    },
    access
  };
};

const joe = obj("joe doe");

joe.access.get("foo"); // undefined
joe.foo; // joe doe
joe.access.get("foo"); // true

Using a getter we can gain knowledge of whether or not a property is being consumed, and store that in a look up table.

Then when processing a state update, we see which parts of the state have changed, and if any of those are in the look up table, we let a render happen.

We need to learn to do a couple of things first:

  • Trigger a render at will
  • Hold state under React's radar

Forced render

In React, the easiest way to force a render is to change state to a new reference.

const [, force] = useState({});

useEffect(() => {
  const timer = setTimeout(() => force({}), 1000);
  return () => clearTimeout(timer);
}, [force]);

We've seen this before, calling force after one second with a new object, triggers a render.

An even simpler way would be to do:

const [, force] = useReducer((x) => !x);

useEffect(() => {
  const timer = setTimeout(force, 1000);
  return () => clearTimeout(timer);
}, [force]);

Either could be packaged into a custom hook

State under the radar

This is a dangerous thing to do, but you can hold state inside a React ref, and control when do you let the React know about the change.

const EvenButton = () => {
  const [count, setCount] = useState(0);
  const refState = useRef(count);

  const onClick = () => {
    refState.current = refState.current + 1;
    if (refState.current % 2 === 0) setCount(refState.current);
  };

  return (
    <button onClick={onClick} data-testid="btn">
      {count}
    </button>
  );
};

Clicking the button updates the ref state, but it only triggers a React renders when the ref state holds an even number.

  • Click once, the i is set to 1, but the button still shows 0
  • Click once again, the internal is set to 2, the button updates to 2

Don't believe me? Here's a test for it!

You can find this test here: src/__tests__/refState.test.tsx.

test("State hidden inside a ref", () => {
  render(<EvenButton />);

  expect(screen.getByTestId("btn")).toHaveTextContent("0");

  fireEvent.click(screen.getByTestId("btn"));

  expect(screen.getByTestId("btn")).toHaveTextContent("0");

  fireEvent.click(screen.getByTestId("btn"));

  expect(screen.getByTestId("btn")).toHaveTextContent("2");
});

Putting it all together

By now you might see what's the trick.

  • Keep state in a React ref
  • Update it as one normally would
  • Let React know about changes, only if certain conditions are met

We'll store the result from the property getters on yet another React ref, and use that to know whether or not to let React know about the change. Remember, we let React know about the change, by forcing a render.

Let's see one possible implementation of useStateWithDeps:

import { useCallback, useEffect, useRef, useState } from "react";

export function useStateWithDeps<State extends Record<string, unknown>>(
  initialState: State
) {
  const [, rerender] = useState({});

  const unmounted = useRef(false);

  useEffect(() => {
    return () => {
      unmounted.current = true;
    };
  }, []);

  const stateRef = useRef(initialState);

  // this is initialized once
  const stateDependenciesRef = useRef<Partial<Record<keyof State, boolean>>>(
    Object.keys(stateRef.current).reduce(
      (prev, curr: keyof State) => ({ ...prev, [curr]: false }),
      {}
    )
  );

  const setState = useCallback(
    (payload: Partial<State>) => {
      let shouldRerender = false;

      const currentState = stateRef.current;

      for (const k in payload) {
        // If the property has changed, update the state
        if (currentState[k] !== payload[k]) {
          currentState[k] = payload[k] as State[typeof k];

          // If the property is accessed, a rerender should be triggered.
          if (stateDependenciesRef.current[k]) {
            shouldRerender = true;
          }
        }
      }

      if (shouldRerender && !unmounted.current) {
        rerender({});
      }
    },
    [rerender]
  );

  return [stateRef, setState, stateDependenciesRef.current] as const;
}

As said earlier, we need to expose a similar API to useState. In this case, we have the state, the state setter, and the dependency tracker.

From top to bottom:

  • The function signature, requires an initial state.

  • Define a force rendering function.

  • An unmount flag. Typical trick to know if the hook we are currently in has unmounted. This is necessary because calls to setState might happen asynchronously. Normally this shoulde be done on the component side, and the flag should be passed to the hook wrapped in a React ref, but in spirit of keeping the function signature small, I placed in here.

  • The next bit, might look confusing, but we are simply going over the initial state and creating a new object, with the same keys, but with false as value. When a component uses a piece of state, we flip this boolean to true. This is how we know if a state property is used.

  • Finally, our state setter. It takes in a payload, which doesn't need to contain all properties of state. It loops over the payload keys, updating the keys that need to be updated, and if it sees an update to a tracked dependency, it sets a flag to force a render. Wrapped in useCallback, to make it stable. The rerender function is stable, so our setState function is also stable.

Usage

This hook is rather special, because even though we have fully constructed it, we still need to do a couple of things from the consumer side to get things working.

We need to tweak how we define useData. Do you still remember that hook?

import { useEffect } from "react";
import { useStateWithDeps } from "./useStateWithDeps";

export const useData = (label: string) => {
  const [stateRef, setState, stateDependencies] = useStateWithDeps({
    data: label.split(""),
    length: 0
  });

  useEffect(() => {
    const data = label.split("");
    setState({ data, length: data.length });
  }, [label, setState]);

  return {
    get data() {
      stateDependencies.data = true;
      return stateRef.current.data;
    },
    get length() {
      stateDependencies.length = true;
      return stateRef.current.length;
    }
  };
};

Now, we'll keep track of which dependencies are read, and notify the consumer only when necessary.

Run our tests again, and we see:

 PASS  src/__tests__/index.test.tsx

πŸŽ‰πŸŽ‰πŸŽ‰πŸŽ‰

We change the label and that itself causes an update, but since that update results in the same length, the hook does not trigger a render once again, even though data did change. Exactly what we wanted!

The only renders we account for are, because of the label state, and the length, when the data that creates it changes.

Critique

This is an optimization technique. Rendering is not necessarily bad. Generally when you have an expensive tree, you'd want to prevent rendering, but not all applications run into such problems.

The use cases for this technique are also contribed. It fits very well for data fetching libraries, where the results are cached, and multiple consumers need to be notified.

Since these consumers might be looking at different parts of state, it makes sense to help by not forcing rendering, if unused parts of state change.

We should understand that these unused parts of the state, are not used by the consumer, but are could be used by library to delivery their value proposition.

For example, if a component doesn't care about the isValidating flag, then we should not force it to render when this flag changes, but the library might still need to know interally if validation is happening.

The implementation is kind of leaky. The hook consumer needs to use getters to get things working correctly.

All in all, it is a great technique and understanding it helps you test your React mental model, but if you haven't needed it so far, chances are you won't need it in the future either, although a library you use might have a version of it.

The extra mile

Let's consider a hook that fetches pokemon. For now let's limit it to the classic 3 starters.

import { useEffect } from "react";
import { useStateWithDeps } from "./useStateWithDeps";

const fetcher = async (
  pkNumber: number,
  { signal }: { signal: AbortSignal }
) => {
  await new Promise((accept) => setTimeout(accept, 1000 * pkNumber));

  return fetch(`https://pokeapi.co/api/v2/pokemon/${pkNumber}`, {
    signal
  }).then((res) => res.json());
};

type Pokemon = {
  height: number;
  id: number;
  weight: number;
  order: number;
  name: string;
};

type PokemonStarter = {
  bulbasaur: Pokemon | null;
  charmander: Pokemon | null;
  squirtle: Pokemon | null;
};

export const useStarterPokemon = () => {
  const [stateRef, setState, stateDependencies] =
    useStateWithDeps<PokemonStarter>({
      bulbasaur: null,
      charmander: null,
      squirtle: null
    });

  useEffect(() => {
    if (!stateDependencies.bulbasaur) return;

    const controller = new AbortController();

    fetcher(1, { signal: controller.signal })
      .then((data) => setState({ bulbasaur: data }))
      .catch((e) => {
        if (controller.signal.aborted) return;
        setState({ bulbasaur: null });
      });

    return () => controller.abort();
  }, [setState]);

  useEffect(() => {
    if (!stateDependencies.charmander) return;

    const controller = new AbortController();

    fetcher(4, { signal: controller.signal })
      .then((data) => setState({ charmander: data }))
      .catch((e) => {
        if (controller.signal.aborted) return;
        setState({ charmander: null });
      });

    return () => controller.abort();
  }, [setState]);

  useEffect(() => {
    if (!stateDependencies.squirtle) return;

    const controller = new AbortController();

    fetcher(7, { signal: controller.signal })
      .then((data) => setState({ squirtle: data }))
      .catch((e) => {
        if (controller.signal.aborted) return;
        setState({ squirtle: null });
      });

    return () => controller.abort();
  }, [setState]);

  return {
    get bulbasaur() {
      stateDependencies.bulbasaur = true;
      return stateRef.current.bulbasaur;
    },
    get charmander() {
      stateDependencies.charmander = true;
      return stateRef.current.charmander;
    },
    get squirtle() {
      stateDependencies.squirtle = true;
      return stateRef.current.squirtle;
    }
  };
};

The useStateWithDeps hook makes it possible to query only for the pokemon we use in our component.

Potentially we could make a hook that fetches every possible pokemon, but does the work only for the pokemon required!

const { charmander } = useStarterPokemon();

Of course, this would also do the trick, assuming usePokemon can handle the input string:

const { pokemon: charmander } = usePokemon("charmander");

As said in the critique, the use cases for useStateWithDeps are kind of limited, and since it is an optimization, you don't need to go and change every use of useState with it.

Happy Hacking πŸŽ‰!