Storing React State in localStorage - and How to Write Tests For It
Storing state in localStorage
is a great way of improving user experience, so
that a user can pick up where they left off when returning to an application.
With React hooks, it's super easy to abstract this functionality so it can be
reused anywhere. In this article, I am going to go over the implementation, and
also show you how you can write tests for it using Jest and React Testing
Library.
We are going to be building a good old counter app, and storing the count
variable in localStorage
so the value is persisted between sessions.
TL;DR — you can find the final code for the project in this Github repo.
Simple counter app
Let's start by getting a simple counter app working. We have the following code:
import { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
return (
<div>
<h1>Counter</h1>
<p>Number: {count}</p>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
)
}
export default App
We have a count variable stored in state, and a decrement and increment button which decreases or increases the number. Now for the fun part!
Reading from localStorage
The localStorage API exposes four methods: getItem()
, setItem()
,
removeItem()
and clear()
. We will want to use getItem()
when we initialise
the component to load in any previously persisted data, and setItem()
to
update the localStorage
when the count variable changes.
To do this, we can take advantage of useState
"lazy initialisation". All this
means is that we can pass a function to useState
which returns the initial
state value, rather than passing the initial value straight in. This means we
can check if there is anything stored in localStorage
and initialise the state
to that value; otherwise initialise it to the default value.
For our code, this will look something like this:
import { useState } from 'react'
function App() {
const [count, setCount] = useState(() => {
const persistedValue = window.localStorage.getItem('count')
return persistedValue !== null ? JSON.parse(persistedValue) : 0
})
return (
<div>
<h1>Counter</h1>
<p>Number: {count}</p>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
)
}
export default App
So, when useState
is initialised, it checks to see if localStorage
contains
any items with the key count
. If so, the state is initialised to that value —
otherwise, the initial state is set to 0
.
Writing to localStorage
At this point, we are reading the state from localStorage
but we never set it.
You can click the buttons and increase or decrease the count, but when you
refresh the page, it will get set back to the default value of 0
.
To address this, we need to call setItem()
when the count
value changes.
There are a couple of ways of doing this — for example, you could add to the
onClick
functions on the buttons:
<button
onClick={() => {
setCount(count + 1)
localStorage.setItem('count', count + 1)
}}
>
+1
</button>
However, in a real-world application where the state might be being updated from
lots of places, this can quickly spaghetti and get out of hand. A simpler
solution is to use useEffect
to update localStorage
every time the count
variable changes:
import { useState, useEffect } from 'react'
function App() {
const key = 'count'
const [count, setCount] = useState(() => {
const persistedValue = window.localStorage.getItem(key)
return persistedValue !== null ? JSON.parse(persistedValue) : 0
})
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(count))
}, [count])
return (
<div>
<h1>Counter</h1>
<p>Number: {count}</p>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
)
}
export default App
The useEffect has count as a dependency — this means every time the count changes, useEffect will run and update localStorage with the new value.
As a side note, this is exactly what useEffect
is designed for - to handle
"side effects" (such as mutating localStorage
) in our React components.
Check out "Stop lying to React about missing
dependecies" if you
want to learn more about how useEffect
works under the hood.
At this stage, we have a fully functioning app which stores the count
in
localStorage
and persists it between sessions. This is great! However, with
custom React hooks, we can abstract this code to make it reusable, rather than
being tightly coupled to the counter functionality. This also makes the code
more testable . Decoupling logic like this from our components means we can test
code in isolation rather than having the localStorage
logic closely linked to
the counter logic. More on that later!
Creating a custom React hook
We can abstract the localStorage
state logic into its own hook:
import { useState, useEffect } from 'react'
const useStateWithLocalStorage = (defaultValue, key) => {
const [value, setValue] = useState(() => {
const persistedValue = window.localStorage.getItem(key)
return persistedValue !== null ? JSON.parse(persistedValue) : defaultValue
})
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue]
}
export default useStateWithLocalStorage
We have pretty much the same code that we had in the counter component, but we
have made it a bit more generic so that it can be reused in any context. It
takes the defaultValue
and key
as arguments, and listens for changes to the
value inside the useEffect
to update localStorage
using setItem()
.
To use it inside our counter component:
import useStateWithLocalStorage from '../hooks/useStateWithLocalStorage'
function App() {
const [count, setCount] = useStateWithLocalStorage(0, 'count')
return (
<div>
<h1>Counter</h1>
<p>Number: {count}</p>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
)
}
export default App
This code is a bit tidier than what we had before, and it's now super easy to
replace a traditional useState
hook with a useStateWithLocalStorage
hook —
you just need to pass the local storage key as the second argument, and the
state will be persisted between sessions.
Testing
We can use
react-hooks-testing-library
to test our implementation of useStateWithLocalStorage
:
import { renderHook, act } from '@testing-library/react-hooks'
import useStateWithLocalStorage from './useStateWithLocalStorage'
const TEST_KEY = 'key'
const TEST_VALUE = { test: 'test' }
describe('useStateWithLocalStorage', () => {
it('should set localStorage with default value', () => {
renderHook(() => useStateWithLocalStorage(TEST_VALUE, TEST_KEY))
expect(JSON.parse(localStorage.getItem(TEST_KEY))).toEqual(TEST_VALUE)
})
it('should set the default value from localStorage if it exists', () => {
// set the localStorage to the test value
localStorage.setItem(TEST_KEY, JSON.stringify(TEST_VALUE))
// initialise with an empty object
const { result } = renderHook(() => useStateWithLocalStorage({}, TEST_KEY))
// check that the value is what is stored in localStorage (and not an empty object)
const [value] = result.current
expect(value).toEqual(TEST_VALUE)
// expect value to be taken from localStorage (rather than empty object)
expect(JSON.parse(localStorage.getItem(TEST_KEY))).toEqual(TEST_VALUE)
})
it('should update localStorage when state changes', () => {
// initialise with test object
const { result } = renderHook(() =>
useStateWithLocalStorage(TEST_VALUE, TEST_KEY),
)
const [, setValue] = result.current
// set the state to something new
const newValue = { anotherValue: 'Some value' }
act(() => {
setValue(newValue)
})
// localStorage should have updated to new value
expect(JSON.parse(localStorage.getItem(TEST_KEY))).toEqual(newValue)
})
})
Let's break this down. Our useStateWithLocalStorage
hook needs to do three
things:
- it needs to initialise the
localStorage
state with the default argument that gets passed in; - if there is already a value stored in
localStorage
, it should initialise the state to that value; - when we update the state, it should update the value stored in
localStorage
.
Initialising the state with the default argument
The first test, "should set localStorage
with default value", deals with
requirement one. We use react-hooks-testing-library's renderHook to initialise
our hook with a test value and a test localStorage
key, and then check that
the value stored in localStorage
matches what we passed in.
Initialising the state to the value in localStorage
The second test, "should set the default value from localStorage
if it
exists", checks that the second requirement is met. We set the localStorage
to
the test value before rendering our hook. We then initialise our state with an
empty object, pulling the result from react-hooks-testing-library's renderHook
method. Finally, we deconstruct the state from result.current
to check that it
matches the localStorage
state rather than an empty object, and check that
localStorage
itself hasn't been updated.
Updating the localStorage state
The last test, "should update localStorage
when state changes", deals with the
third requirement. We initialise our hook, and this time pull the setValue
function from result.current
so that we can update the state. We then check
that localStorage
has been updated with the new state value.
We can also write some tests to check that the counter integrates with
localStorage
properly:
it('should initialise with the value stored in localStorage', () => {
// set the localStorage to a value other than 0
localStorage.setItem('count', 15)
// render the App and check it initialises to the value in localStorage
render(<App />)
screen.getByText(/Number: 15/)
// click the increment button and check the localStorage is updated
const incrementButton = screen.getByText(/\+1/)
fireEvent.click(incrementButton)
screen.getByText(/Number: 16/)
expect(localStorage.getItem('count')).toBe('16')
})
Here, we set the localStorage
count to an initial value of 15, then render our
component and click on the increment button. We can then check that the count
itself has increased, and also that the value stored in localStorage
matches.
It's also a good idea to clear localStorage
between tests to ensure that each
test is fully isolated:
beforeEach(() => {
localStorage.clear()
})
Note that if you are using an older version of jest/jsdom, you will need to mock
localStorage
to get it to work when you run your tests. In your
setupTests.js
file, you can add the following:
class LocalStorageMock {
constructor() {
this.store = {}
}
clear() {
this.store = {}
}
getItem(key) {
return this.store[key] || null
}
setItem(key, value) {
this.store[key] = value
}
removeItem(key) {
delete this.store[key]
}
}
global.localStorage = new LocalStorageMock()
This creates a barebones implementation of localStorage
so we can run the
tests above and check that everything is working as expected.
To sum up, using localStorage
is a great way of improving user experience by
storing state between sessions, and by using custom React hooks, you can do it
in a reusable, maintainable and testable way.