react-tracked - so helpful tool

github x github

Good old React and good old React problems. Especially when it comes to global state management, you will probably think - another article about why we should switch to React Context instead of using Redux. Not at all. It won't be a good idea in most cases anyway, but do we need to use Redux everywhere? What are other options? No, no, other than Zustand, Recoil, MobX? ;)

What if you like to use React Context or even useState because it seems pretty natural and convenient. Why not using it as is?

If you don't know why, you will probably need to read some more articles on the net, not only this one because I won't focus much on how React works. Long story short - it has quite a significant performance impact when it comes to functional components rerenders. Of course, nothing is only black or white. It all depends, sometimes when you don't care much about performance stuff, it is totally fine to use React Context everywhere.

React doesn't provide any optimization as is. Of course, you can use memo for components and decide when they should refresh. Let's be honest. Managing it all in a big project with more than one developer will be... challenging. In more significant projects, it is quite problematic to keep such a codebase in good shape. It is why we have so many state management tools and approaches in React community.

Here are some of my thoughts about a very interesting tool which I think isn't so popular yet but should be. The react-tracked isn't replacing other state management tools per se. It is a tool that helps with rerenders optimization. You can use it with Redux, Zustand, and standard useState/useRedux. In combination with useState/useRedux, it is a perfect replacement for React Context but optimized. And this is a combination on which I want to focus here.

Who is behind this library?

Daishi Kato - the author of this library, has excellent experience with React and performance-related stuff. He is also the author of a couple of good libraries like Jotai and Valtio. He is also a maintainer of Zustand.

Let's see what react-tracked has to offer.

It was built using JavaScript Proxies underneath. In simple words, what it does is tracking state property changes. It only triggers rerender when a property on which component depends changes. Normally React will rerender all functional components, which are its children on every state change in the parent component. We don't want it if we don't rely on any changed prop in the state object. We would need it only for those child components which listen for these changes.

Let's see how to use it with useState.

It is probably the simplest use case and, for me, most practiced because a very little boilerplate is required to achieve global state functionality. Similar to React Context, but optimized.

So what you need for a global state is:

// store.js
import { useState } from 'react';
import { createContainer } from 'react-tracked';

const useValue = () => useState({ exampleItem: '' });

export const {
  Provider,
  useTrackedState,
  useUpdate: useSetState,
} = createContainer(useValue);

We use createContainer to prepare all helpers, which we will incorporate in React app. Of course, this is a straightforward example. I will show a more complicated one later. And you can see more examples here.

So the next step would be to decide where we will need to use tracked state. We need to wrap such a code section with the Provider. Like:

// Some parent component
import { Provider } from './store';

const Parent = () => {
  return (
    <Provider>
      <Child1 />
      <Child2 />
    </Provider>
  );
};

Now let's see how to use the global state in child components. If you want to rely on exampleItem in the Child2 component, you can do something like:

// Child2.js
import { useTrackedState } from './store';

const Child2 = () => {
  const trackedState = useTrackedState();
  return <div>{trackedState.exampleItem}</div>;
};

What is important is to use the useTrackedState as low in the hierarchy of components as possible. If we use it in the parent component, it will act similar to standard useState, and it will trigger rerenders of all children. Here we will rerender only Child2 when the state is changed. Child1 will stay intact. Without the library, you need to keep this in mind and optimize rerenders using React.memo.

What with state change triggers.

You can have access to them from every component inside the Provider. Let's say that we have a button that triggers state change, and it is located in the Parent component:

// Some parent component
import { Provider, useSetState } from "./store";

const Parent = () => {
  const setExampleState = useSetState();

  const handleStateChange = (e) => {
    e.preventDefault();
    setExampleState((prev) => ({ ...prev, exampleItem: '123' }));
  };

  return (
    <button onClick={handleStateChange} type="button">Trigger</button>
    <Provider>
      <Child1 />
      <Child2 />
    </Provider>
  );
};

We import useSetState, and now we can trigger state changes. As you can see, we do this in the Parent component, and we consume that state in the Child2 component. This way, Child1 will stay intact. Only Child2 will rerender.

In short, this is the primary usage of the library. Of course, there are plenty of other use cases. You can find them in the docs. But let's take a closer look at another more complicated example which I think we all use pretty often. Let's see how to handle global states with async logic. For example, making some calls to API on the initial render. The best example is user data synchronization in-app or some WebSockets connection using provider, etc.

Our store for such case will look similar to:

// store.js for async logic
import React from 'react';
import { createContainer } from 'react-tracked';
import { useAuthState } from './AuthContextStore';
import apiFetch from '../apiFetch';

const useValue = () => {
  const { isLoggedIn } = useAuthState(); // custom hook for handling auth

  const [state, setState] = React.useState({
    userDataPending: undefined,
    userDataError: undefined,
    user: null,
  });

  const fetchData = React.useCallback(async () => {
    try {
      setState((prev) => ({ ...prev, userDataPending: true }));
      const res = await apiFetch.get('/user'); // custom api fetch tool
      if (res.data) {
        setState((prev) => ({ ...prev, user: res.data }));
      }
      setState((prev) => ({ ...prev, userDataPending: false }));
    } catch (error) {
      setState((prev) => ({
        ...prev,
        userDataPending: false,
        userDataError: error, // for simplicity and demo
      }));
    }
  }, []);

  React.useEffect(() => {
    if (isLoggedIn && fetchData) {
      fetchData();
    }
  }, [isLoggedIn, fetchData]);

  return [state, setState];
};

export const { Provider: UserProvider, useTrackedState: useUserState } =
  createContainer(useValue);

With something similar, you will get access to the global user data store. You would be able to use it in every child component wrapped with Provider.

Summary

To sum up the article, please check two example sandboxes. The first one with proper react-tracked usage and the second one demonstrates the most common mistake developers make - when they handle the state too early in the components tree.

codesandbox link

codesandbox link

It isn't the magic tool that will think for you. You will always need to consider what and how you can optimize. It is required to have a good understanding of how React works. But react-tracked still saves a ton of work. When used at the beginning of the project, it also saves a ton of refactoring.

I hope that it was helpful. Let me know. Follow me on Twitter and Github for more updates.