DEV Community

Matt Crowder
Matt Crowder

Posted on

Writing a custom react hook that persists to local storage

So, you're writing some react hooks, and you say to yourself, "I'd love for this local state to persist on refreshes".

Let's write a custom hook that persists to localStorage!

Setup

$ create-react-app local-storage-hook
$ cd local-storage-hook
$ yarn eject # accept all of the prompts
$ yarn add -D jest-localstorage-mock react-testing-library jest-dom
$ touch src/use-local-storage-set-state.js && touch src/use-local-storage-set-state.test.js && touch src/setupTests.js

Then, open up package.json, and edit the jest config,

add "jest-localstorage-mock" to the setupFiles section.

So now it looks like this:

"setupFiles": [
   "react-app-polyfill/jsdom",
   "jest-localstorage-mock"
]

Then, add the following property to the jest config,

"setupTestFrameworkScriptFile": "<rootDir>/src/setupTests.js"

Make src/setupTests.js the following:

import "react-testing-library/cleanup-after-each";
import "jest-dom/extend-expect";

(Before proceeding, if you are getting a weird error while running yarn test, then please do rm -rf node_modules && yarn and then this will solve those issues).

Alright! Let's write some tests!

// use-local-storage-set-state.test.js
import React from "react";
import { useLocalStorageSetState } from "./use-local-storage-set-state";

test("throws when name is not provided", () => {
  expect(() => useLocalStorageSetState(0)).toThrow();
});

When running yarn test, this fails, so let's implement the source code:

// use-local-storage-set-state.js
export const useLocalStorageSetState = (initialValue, name) => {
  if (!name) {
    throw new Error("Name must be provided to persist to localStorage");
  }
};

Now, when running yarn test, this test passes!

Unfortunately, this doesn't do much. Let's add extra tests to show what we're going for!

// use-local-storage-set-state.test.js
import React from "react";
import { render, fireEvent } from "react-testing-library";
import { useLocalStorageSetState } from "./use-local-storage-set-state";

test("throws when name is not provided", () => {
  expect(() => useLocalStorageSetState(0)).toThrow();
});

test("persists on component unmounts and rerenders", () => {
  function Comp() {
    const [value, setValue] = useLocalStorageSetState(0, "value");

    return (
      <div>
        {value}
        <button onClick={() => setValue(value + 1)}>Add value</button>
      </div>
    );
  }
  const { getByText, rerender, unmount } = render(<Comp />);
  expect(getByText(/0/i)).toBeInTheDocument();
  fireEvent.click(getByText(/add value/i));
  expect(getByText(/1/i)).toBeInTheDocument();
});

Now let's add the source code:

// use-local-storage-set-state.js
import React from "react";

export const useLocalStorageSetState = (initialValue, name) => {
  if (!name) {
    throw new Error("Name must be provided to persist to localStorage");
  }

  const [value, setValue] = React.useState(initialValue);

  return [value, setValue];
};

Now, when running yarn test, the tests pass!

Let's add more to the tests to show what more functionality we want, add the following:

unmount();
rerender(<Comp />);
expect(getByText(/1/i)).toBeInTheDocument();

We're back to failing again! Let's add the proper source code.

Let's think about this before writing some random code.
When value changes, we want to persist that value into localStorage. So, value changes, function needs to fire.. This is exactly what useEffect is for!
Before we proceed, let's install the store npm module for efficient cross browser localStorage support:
yarn add store

Here is the source code with useEffect:

// use-local-storage-set-state.js
import React from "react";
import store from "store";

export const useLocalStorageSetState = (initialValue, name) => {
  if (!name) {
    throw new Error("Name must be provided to persist to localStorage");
  }

  const [value, setValue] = React.useState(initialValue);

  React.useEffect(
    () => {
      store.set(name, value);
    },
    [value]
  );
  return [value, setValue];
};

yarn test is still failing, we're almost there! We need to read from localStorage for the initial value.

// use-local-storage-set-state.js
import React from "react";
import store from "store";

export const useLocalStorageSetState = (initialValue, name) => {
  if (!name) {
    throw new Error("Name must be provided to persist to localStorage");
  }
  const actualInitialValue =
    store.get(name) !== undefined ? store.get(name) : initialValue;
  const [value, setValue] = React.useState(actualInitialValue);

  React.useEffect(
    () => {
      store.set(name, value);
    },
    [value]
  );
  return [value, setValue];
};

And now, yarn test is passing!

Now, there are a couple extra edge cases that we missed here, let me know in the comments if you would like those covered, but, you should be able to implement those yourself!

Source code available here: https://github.com/mcrowder65/local-storage-hook

Oldest comments (2)

Collapse
 
patroza profile image
Patrick Roza • Edited

Interesting to use the useEffect hook aswell, though you can also create a function that combines calling the store.set and state set. Not sure which yet which i’d prefer.

What’s your thoughts on using context, instead of useState? useState and store provide a similar function but with context it becomes something that can be shared, which would address the case of having multiple consumers of the state, each updating accordingly when the storage is updated.
Perhaps a refactoring for when the need arises.

Collapse
 
mcrowder65 profile image
Matt Crowder

Yeah! Before I wrote this article, I actually wasn't using useEffect at all! And I was just invoking whenever the setter was invoked: github.com/mcrowder65/mooks/commit...

Context is great, but underneath, it would still need to do useState. I would be happy to provide an example on how to do useContext.