DEV Community

Tayyib Cankat
Tayyib Cankat

Posted on • Edited on • Originally published at t410.me

How to persistently store state in React? [usePersist]

useState is one of the basic hooks in React. But you can not keep your state persistent with useState. When the user refreshes the page, the state is gone. So How do we keep persistent data/state in React? We can write a custom hook that persists data.

Show Me the Code

usePersist.ts

import { useCallback, useState } from "react";

interface UsePersistProps<T> {
    stateName: string;
    initialValue: T;
}

const usePersist = <T>({ stateName, initialValue }: UsePersistProps<T>): [T, (value: T) => void] => {
    const name = `persist/${stateName}`;

    const getFromStorage = <T>(name: string, defaultValue?: T) => {
        try {
            const val = JSON.parse(localStorage.getItem(name) + "");
            if (val !== null) {
                return val;
            } else {
                localStorage.setItem(name, JSON.stringify(defaultValue));
            }
        } catch {
            return defaultValue;
        }
    };

    const [state, setState] = useState<T>(getFromStorage<T>(name, initialValue));

    const setValue = useCallback(
        (value: T) => {
            localStorage.setItem(name, JSON.stringify(value));
            setState(value);
            console.log(name, value);
        },
        [name]
    );

    return [state, setValue];
};

export default usePersist;

Enter fullscreen mode Exit fullscreen mode

Usage

const [persistedState, setPersistedState] = usePersist<string>({
    stateName: "myPersistedState",
    initialValue: "Hello World",
});
Enter fullscreen mode Exit fullscreen mode

How?

OK, the code above might look confusing. I might have messed up, or that may be the ideal solution for this specific task. You be the judge.

The custom hook saves the state in localStorage and returns it when needed. This is basically it.

Let's rewrite it step by step to understand it better.

Step 1

We have to give a name to save the data to the localStorage. We also may want to give an initial value to the custom hook like we do for useState. Like in useState, we also may want to know the type of the data that we are going to save. To do this, we can use generics.

interface UsePersistProps<T> {
    stateName: string;
    initialValue: T;
}

const usePersist = <T>({ stateName, initialValue }: UsePersistProps<T>) => {
    const name = `persist/${stateName}`;

    const setValue = (value: T) => {};
};

export default usePersist;
Enter fullscreen mode Exit fullscreen mode

Step 2

Let's start writing the set logic. First, let's keep the data in useState.

import { useState } from "react";

interface UsePersistProps<T> {
    stateName: string;
    initialValue: T;
}

const usePersist = <T>({ stateName, initialValue }: UsePersistProps<T>) => {
    const name = `persist/${stateName}`;
    const [state, setState] = useState<T>(initialValue);
};

export default usePersist;
Enter fullscreen mode Exit fullscreen mode

Onto the set logic. As you might have guessed, we save the data to the localStorage. But I also want to save the data to the useState. This way, we won't have to read the data from the localStorage to return the data.

const setValue = (value: T) => {
    localStorage.setItem(name, JSON.stringify(value));
    setState(value);
};
Enter fullscreen mode Exit fullscreen mode

It's pretty straightforward, right? However we will have an infinite render loop issue if we don't wrap this inside useCallback. React doesn't know if the setValue function will change or not. But we do. We might skip adding the function to the dependency array when we use it inside useEffect but eslint will annoy us.

Further readings:
https://reactjs.org/docs/hooks-reference.html#usecallback
https://github.com/facebook/react/issues/14920

Let's wrap it inside the useCallback hook and pass the name dependency even we know that we won't change the name.

const setValue = useCallback(
    (value: T) => {
        localStorage.setItem(name, JSON.stringify(value));
        setState(value);
    },
    [name]
);
Enter fullscreen mode Exit fullscreen mode

Step 3

Let's write the get logic.

const getFromStorage = () => {
    try {
        const val = JSON.parse(localStorage.getItem(name) + "");
        if (val !== null) {
            return val;
        } else {
            localStorage.setItem(name, JSON.stringify(initialValue));
        }
    } catch {
        return initialValue;
    }
};
Enter fullscreen mode Exit fullscreen mode

Basically, we are trying to get the data from the localStorage. If the data does not exist, then we save it to the localStorage. The code is wrapped inside the try-catch block just in case if the data cannot be parsable. If happens so, the code returns the initialValue.

Step 4

Let's finalize the code

Put the getFromStorage function above the useState.
Pass the getFromStorage() function call to the useState as so

const [state, setState] = useState<T>(getFromStorage());
Enter fullscreen mode Exit fullscreen mode

Now it should look like this

import { useCallback, useState } from "react";

interface UsePersistProps<T> {
    stateName: string;
    initialValue: T;
}

const usePersist = <T>({ stateName, initialValue }: UsePersistProps<T>) => {
    const name = `persist/${stateName}`;
    const getFromStorage = () => {
        try {
            const val = JSON.parse(localStorage.getItem(name) + "");
            if (val !== null) {
                return val;
            } else {
                localStorage.setItem(name, JSON.stringify(initialValue));
            }
        } catch {
            return initialValue;
        }
    };

    const [state, setState] = useState<T>(getFromStorage());

    const setValue = useCallback(
        (value: T) => {
            localStorage.setItem(name, JSON.stringify(value));
            setState(value);
        },
        [name]
    );
};

export default usePersist;
Enter fullscreen mode Exit fullscreen mode

Now let's return the set and get functions like React does for useState.

return [state, setValue];
Enter fullscreen mode Exit fullscreen mode

Now the final code should be as such

import { useCallback, useState } from "react";

interface UsePersistProps<T> {
    stateName: string;
    initialValue: T;
}

const usePersist = <T>({ stateName, initialValue }: UsePersistProps<T>): [T, (value: T) => void] => {
    const name = `persist/${stateName}`;

    const getFromStorage = () => {
        try {
            const val = JSON.parse(localStorage.getItem(name) + "");
            if (val !== null) {
                return val;
            } else {
                localStorage.setItem(name, JSON.stringify(initialValue));
            }
        } catch {
            return initialValue;
        }
    };

    const [state, setState] = useState<T>(getFromStorage());

    const setValue = useCallback(
        (value: T) => {
            localStorage.setItem(name, JSON.stringify(value));
            setState(value);
        },
        [name]
    );

    return [state, setValue];
};

export default usePersist;
Enter fullscreen mode Exit fullscreen mode

Usage

Let's use it inside a component

function App() {
    const [persistentState, setPersistentState] = usePersist<string>({
        stateName: "myState",
        initialValue: "Hello World",
    });

    useEffect(() => {
        setPersistentState("Hello, I'm persistent");
    }, [setPersistentState]);

    useEffect(() => {
        console.log(persistentState);
    }, [persistentState]);

    return (
        <>
            <p>{persistentState}</p>
        </>
    );
}
Enter fullscreen mode Exit fullscreen mode

You can verify it working by checking your developer console. You might also want to delete the entry in your localStorage.

Screenshot of the developer console consists both localStorage screen and the console

You can also use the usePersist in Context API.
I am using it for changing between the dark mode and the light mode.

import { usePersist } from "hooks";
import { createContext, FC, useEffect, useState } from "react";
interface DarkModeContextState {
    darkMode: boolean;
    setDarkMode: (darkMode: boolean) => void;
}

const contextDefaultValues: DarkModeContextState = {
    darkMode: true,
    setDarkMode: () => {},
};

export const DarkModeContext = createContext<DarkModeContextState>(contextDefaultValues);

const DarkModeProvider: FC = ({ children }) => {
    const [persistedDarkMode, setPersistedDarkMode] = usePersist<boolean>({
        stateName: "darkMode",
        initialValue: contextDefaultValues.darkMode,
    });
    const [darkMode, setDarkMode] = useState<boolean>(persistedDarkMode);

    useEffect(() => {
        setPersistedDarkMode(darkMode);
    }, [darkMode, setPersistedDarkMode]);

    return (
        <DarkModeContext.Provider
            value={{
                darkMode,
                setDarkMode: (val: boolean) => {
                    setDarkMode(val);
                },
            }}
        >
            {children}
        </DarkModeContext.Provider>
    );
};

export default DarkModeProvider;
Enter fullscreen mode Exit fullscreen mode

Conclusion

Thanks for reading so far.

You might ask

"Why don't you use a package to handle this?".
We can, of course. But, I wanted to give an idea of how to solve a pretty basic problem. I prefer understanding the solution that I am using.

"Why don't we set to and get from the local storage right inside the component?"
That should work but I wanted to go with a more elegant solution.

If you have any more questions or any feedback, please let me know. Hopefully this might be a solution to your problem and/or give you an insight how to write a custom hook.

Top comments (0)