modern, weightless, productive and very flexible state manager

The article is a transcript of part report.

So let's start with the official description:

Zustand is a small, fast and scalable state management solution based on the principles of Flux and immutable state. It has a convenient API based on hooks, does not create unnecessary boilerplate code and does not impose strict rules of use. Has no problems with Zombie children and context loss and works great in React concurrency mode.

Zustand works in any environment:

Its architecture is based on a publish/subscribe object and a single hook implementation for React.

Lightweight

Zustand bundlephobia

Zustand bundlephobia

There’s not even anything to add here – 693 bytes! I don’t know if it’s possible to find an implementation even smaller than this.

Simple

Zustand has a simple syntax for creating a repository:

const storeHook = create((get, set) => { ... store config ... });

For example, let's create a simple storage:

import { create } from 'zustand';

export const useCounterStore = create((set) => ({
    count: 0,

    increment: () => set((state) => ({ count: state.count + 1 })),
    decrement: () => set((state) => ({ count: state.count - 1 })),
}));

The repository can be created and modified both in the context of the React application and in javascript outside the React context (not every state manager is capable of this).

Zustand uses immutability to detect changes – familiar methods of working with data in React.

In Zustand you can create as many storages as your heart desires.

import { create } from 'zustand';

export const useCatsStore = create((set) => ({
    count: 0,

    increment: () => set((state) => ({ count: state.count + 1 })),
    decrement: () => set((state) => ({ count: state.count - 1 })),
}));

export const useDogsStore = create((set) => ({
    count: 0,

    increment: () => set((state) => ({ count: state.count + 1 })),
    decrement: () => set((state) => ({ count: state.count - 1 })),
}));

The generated hook has a simple syntax

storeHook: (selector) => selectedState;

where selector is a simple function to retrieve data from the storage state passed to it

const stateData = storeHook((state) => {
    // извлечение данных  из  state
    ...
    return selectedState;
});

Created by React hook, easy to use in components

function Counter() {
    const { count, increment } = useCounterStore((state) => state);

    return <button onClick={increment}>{count}</button>;
}

Comfortable

For convenience, the hook has static methods:

{
    getState: () => state,
    setState: (newState) => void,
    subscribe: (callback) => void,
    ...
}

Static methods are very convenient to use in handlers

function Counter() {
    const count = useCounterStore((state) => state.counter);

    function onClick() {
        const state = useCounterStore.getState();

        useCounterStore.setState({
            count: state.count + 3;
        });
    }

    return <button onClick={onClick}>{count}</button>;
}

You can use asynchronous calls in the repository:

const useCounterStore = create((set) => ({
    count: 0,

    increment: async () => {
        const resp = await fetch('https://my-bank/api/getBalance');
        const { balance } = await resp.json();

        set((state) => {
            return { count: state.count + balance };
        });
    },
}));

Asynchronous calls can also be made in a component

function Counter() {
    const count = useCounterStore((state) => state.counter);

    function onClick() {
        const resp = await fetch('https://my-bank/api/getBalance');
        const { balance } = await resp.json();

        useCounterStore.setState({
            count: count + balance,
        });
    }

    return <button onClick={onClick}>{count}</button>;
}

Methods can also be created outside of the repository

const useCounterStore = create(() => ({
    count: 0,
}));

const increment = () => {
    const state = useCounterStore.getState();
    useCounterStore.setState({ count: state.count + 1 });
};

In Zustand, just like in Redux, you can create your own middleware!
It comes with ready-made middlewares:

  • persist: to save/restore data to/from localStorage

  • immer: for simple mutable state change

  • devtools: to work with the redux devtools extension for debugging

Productive

What's under the hood?

Here is a naive implementation of the Zustand hook that displays its logic

function useStore(selector) {
    const [prevState, setPrevState] = useState(store.getState(selector));

    useEffect(() => {
        const unsubscribe = store.subscribe((newState) => {
            const currentState = selector(newState);

            if (currentState !== prevState) {
                setPrevState(currentState);
            }
        });

        return () => {
            unsubscribe();
        };
    }, [selector]);

    return prevState;
}

At the time the hook is initialized, the current state for the selector is selected from the store and written to the hook state.
Next, the effect subscribes to changes in the pusb/sub storage object, in which, when updates to the storage data occur, data is retrieved using a selector. It then compares the current data references with the reference to the data previously stored in the hook state. If the links are not equal (and we remember that when immutable data changes in an object, the links to the data change) – the hook state update method is called.

How much simpler and more effective!?
Link comparison is a simple and very effective operation.

Since the store data is stored outside of the React context (in an external pub/sub object), this hook will skip store state changes between renders in Concurrency mode in React 18+, so the actual implementation of the Zustand hook is based on the new React hook for synchronously redrawing the component tree

function useStore(store, selector, equalityFn) {
    return useSyncExternalStoreWithSelector(
        store.subscribe,
        store.getState,
        store.getServerState || store.getInitialState,
        selector,
        equalityFn,
    );
}

In Concurrency mode, if the data in the store changes, React will interrupt the parallel construction of the DOM tree and will run a synchronous render in order to display the new data changes in the store – this ensures that no change in the store will be missed and will be rendered.
The React team is working to avoid rude interruptions to React concurrency in the future and render changes in a timely manner in a concurrent context.

Optimization methods

Optimization methods follow from the hook implementation code above – the hook directly depends on the selector, so we must ensure that the reference to the selector itself and the data returned by it are constant!

There are only 3 simple optimization methods

  1. if the selector does not depend on internal variables, move it outside the component, this way the selector will not be created every time and the reference to it will be constant

const countSelector = (state) => state.count;

function Counter() {
    const count = useCounterStore(countSelector);
    return <div>{count}</div>;
}
  1. if the selector has parameters, wrap it in useCallback – this way we will ensure that the reference to the selector is persistent depending on the value of the parameter

function TodoItem({ id }) {
    const selectTodo = useCallback((state) => state.todos[id], [id]);
    const todo = useTodoStore(selectTodo);

    return (
        <li>
            <span>{todo.id}</span>
            <span>{todo.text}</span>
        </li>
    );
}
  1. if the selector returns a new object each time – wrap the selector call in useShallow – if the data itself in the new object has not really changed – the useShallow hook will return a link to the previously saved object

import { useShallow } from 'zustand/react/shallow';

const selector = (state) => Object.keys(state);

function StoreInfo() {
    const entityNames = useSomeStore(useShallow(selector));
    return (
        <div>
            <div>{entityNames.join(', ')}</div>
        </div>
    );
}

if the selector also has parameters and returns a new object, wrap it in useCallback and then in useShallow

import { useShallow } from 'zustand/react/shallow';

function TodoItemFieldNames({ id }) {
    const selectFieldNames = useCallback((state) => Object.keys(state.todos[id]), [id]);
    const fieldNames = useTodoStore(useShallow(selectFieldNames));

    return (
        <li>
            <span>{fieldNames.join(', ')}</span>
        </li>
    );
}

There is another “turbo” way to optimize the display of data changes in the store bypassing the React render cycle – use a subscription to data changes and manipulate DOM elements directly

function Counter() {
    const counterRef = useRef(null);
    const countRef = useRef(useCounterStore.getState().count);

    useEffect(
        () =>
            useCounterStore.subscribe((state) => {
                countRef.current = state.count;
                counterRef.current.textContent = countRef.current;
            }),
        [],
    );

    return (
        <div>
            <span>Count: </span>
            <span ref={counterRef}>{countRef.current}</span>
        </div>
    );
}

When initializing a component, we remember the current state in a mutable object countRef and when the component is redrawn, we display it. If the subscription is triggered, we get a new state and write it to a mutable object countRef and then change the text content of the link to the DOM element counterRef.
If React then redraws our component for some reason, we will always have the current state of the storage in the mutable object countRef.

Best practics

  1. Keep all related entities in one repository

Zustand allows you to create an unlimited number of storages, but in practice a separate storage needs to be created only for data that is not really connected with data in other storages, otherwise you will go back to the past – the well-known model hell problem from MVC and FLUX (a large number of models having chaotic connections with non-transparent logic of interaction with multidirectional data flows, which, as a rule, always leads to update loops and very long debugging in the evenings to detect these loops – more details in the report).

Currently, the younger generation of developers who have not worked with MVC and FLUX and have not studied the theory, creating various atomic, molecular, proton, neutron and all sorts of quark state managers, are stepping on the rake that the industry went through many years ago. Related data should be stored in the same storage.
Otherwise, the question arises: why did you move the local state from the Counter component to a separate storage? What benefits did this bring, besides the inconvenience and additional code with its added complexity?

The creation of separate storage facilities is justified only for:

  1. Create storage methods outside of its description

Despite the fact that Zustand allows you to create storage methods directly in it, I recommend not creating storage methods inside it – if you have many entities in your storage or the entities are complex, and their processing methods are quite voluminous – the description of your storage will become unreadable and unintelligible and poorly supported.

  1. Access data in the storage only using its methods!

I have been designing and maintaining databases for many years, and it is not clear to me why the part of the database, represented on the client by the storage, which is part of the business logic of the data, does not provide this business logic!
There is no code to ensure data integrity, consistency or security!
The vast majority of application state stores that I have seen in production are simple Json stores! This is not a state store, but rather simple json stores.

If you understand that this data is the core of the application around which the application is built, then it becomes clear that how this storage is implemented is how the reliability of the application will be. If the storage (the core of the application) is a simple Json storage, then there is a 99.99% chance that the application will be plagued by a large number of bugs. One way or another, the data validation code will be spread out and duplicated across different parts of the application. And the lack of ensuring data integrity and consistency is guaranteed to lead to bugs that will constantly arise in a large team.

To create reliable storage and thereby eliminate most bugs in the application:

  • the developer should not have direct access to the internals of the repository

  • data should be retrieved and processed exclusively using storage methods

  • Storage methods must ensure data integrity and consistency

import { create } from 'zustand';

// никода не экспортируем сам хук с его методами доступа к хранилищу!
const useCounterStore = create(() => ({
    count: 0,
}));

// экспортируем хук - не даем доступ ко всему содержимому хранилища
export const useCounter = () => useCounterStore((state) => state.count);

// экспортируем метод
export const increment = () => {
    const state = useCounterStore.getState();
    useCounterStore.setState({ count: state.count + 1 });
};

I also recommend performing a complete cloning of data at the input and output of the methods – not a single June will break your storage by mutating data from the storage (for an experiment, try changing anything in the returned data – you will not be pleasantly surprised by the result – you will directly change the data in storage).

Encapsulating business logic in storage methods will also allow you to painlessly change the state manager if necessary.

Conclusion:

If you look at the implementation of Zustand and look back 10 years ago, when we already had active models like pub/suband all we had to do was combine the entire zoo of these models into one repository and implement an analogue of the hook (which is easily implemented in old class components), we can understand that at some point the industry took a wrong turn and we followed the wrong ones and only after 10 years, we finally took the right road: unidirectional data flow (component -> user response -> changing data in the store -> hook for displaying changes) and storing related data structures in one store.

Along the way, we first had to invent FLUX, and then go even further away from a simple Redux solution, while at the same time groping around for the right solution using thousands of state manager implementations.

But all's well that ends well!

Zustand is a developer's dream!

  • compact

  • easy to use

  • does not add cognitive load

  • productive

  • easily optimized

  • easily scalable

  • having easily maintainable code

  • eliminating problems like Zombie children and context loss

  • supporting work in React concurrency mode

We have been using Zustand almost since its inception and experience only positive emotions from its use.

Start actively using Zustand in your projects – you will be pleasantly surprised by the simplicity and ease of working with it.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *