Local storage in React
Local storage is a browser API that lets you save simple data to the browser. This data is not available on a server, it can only be accessed client-side. Local storage is a fantastic place to keep simple application data. If a user closes or refreshes the tab then their data will be not be lost.
MDN documentation is available here.
The local storage API can be accessed from the window.localStorage
key. Since
window
is global in browsers, you will most often access it with
localStorage
.
The API looks like this:
// Returns the value given string 'key'. Null if no value is found.localStorage.getItem(key: string): string | null;// Set the string 'value' to 'key'.// Might throw an error if localStorage is not availablelocalStorage.setItem(key: string, value: string): void;// Remove the value associated with string 'key'.localStorage.removeItem(key: string): void;// Remove all values for all keyslocalStorage.clear(): void;
Start Simple
There is nothing React specific in this API. However, wrapping the API with some logic to make it more Reactful will have it work better in your applications. That's what this will cover.
The simplest approach is to just invoke the API whenever you need to save or get something. I'll use the To-Do app as an example. You can grab the code used here.
The simplest way to start is to use the APIs above verbatim. The APIs only accept strings, which means saving complex data needs to be simplified to a string. The most common way to do this is to save your data as JSON, which is a string.
const onAddTodo = (text: string) => {// Save the to-dos as state, as normal.setTodos([...todos, { text }])// Also save the todos as JSON.localStorage.setItem('todos', JSON.stringify([...todos, { text }]))}
The above code saves the Todos when they are changed. In your application,
whenever the state changes you can call the setItem
API to overwrite the
previous value and save the data.
Saving the data is only half the work though. You also need to load the data. And this is a little more complicated.
Load, but only once
When your application loads you want to load the data that you saved earlier. This will allow the user to refresh the page or close the tab. When the user comes back to your application and it loads for the first time, the data will load.
The simplest (but not great) approach is to load the data when the component executes and put the result in the state. This will only run each time the component renders but only sets the state for the first time.
const TodoList = ({ name }: TodoListProps) => {const loaded = JSON.parse(localStorage.getItem('todos') || '[]')const [todos, setTodos] = useState<Todo[]>(loaded)return (...)}
This will parse
the data stored in local storage under the key todos
, or if
no data is found, it will parse the string '[]'
. This is because the
JSON.parse
API expects a value, and our default value for the state is an
empty array. Another way of writing this would be:
const loaded = localStorage.getItem('todos') ? JSON.parse(localStorage.getItem('todos')) : []
The first way duplicates less code but calls JSON.parse
no matter what.
Hurray! The Todo app now loads the list on startup and saves it whenever it changes. But there are a few issues.
Handling multiple lists
The first issue is that all the Todo lists save using the same key. This will become an issue the moment a user has multiple lists.
To fix this problem, the key
that is used to save the data needs to be based
on a unique attribute of the list. Ideally, this would be a unique primary key,
but another attribute like name
is close.
How the name is assigned is outside the scope of this article, but if it were passed in you could do:
interface TodoListProps {name: string}const TodoList = ({ name }: TodoListProps) => {// The key to save and load is now list specificconst key = `todo-list-${name}`const loaded = JSON.parse(localStorage.getItem(key) || '[]')const [todos, setTodos] = useState<Todo[]>(loaded)const onAddTodo = (text: string) => {setTodos([...todos, { text }])localStorage.setItem(key, JSON.stringify([...todos, { text }]))}return (...)}const App = () => <TodoList name="Default" />
Awesome! Now the user can have multiple lists that all independently save their todos.
This works, but it adds a lot of extra logic to the TodoList
component just to
manage the items. There are two big issues with this though:
- The logic of accessing local storage on a given key is duplicated.
- The loading of the data happens each time the component renders. The only time it needs to load is the first time, but it currently runs each time.
- Every time the state is changed the local storage logic also needs to be added.
Tackling the first one requires creating a custom hook.
Making it a hook
Any JavaScript code can access localStorage
and save or get values; there is
nothing special about React here. But to make the API feel native, you can build
it into a hook. This hook will accept the key
as a string and scope the
requests to that key. It will return an API to get, set, or remove that key.
type StorageOptions = {json?: boolean}export const useStorage = <T = any>(key: string, { json }: StorageOptions = {}) => {const get = (): T | null => {const value = localStorage.getItem(key)if (!value) {return value as unknown as T}if (json) {try {return JSON.parse(value)} catch {}}return value as unknown as T}const set = (value: T) => {const saving = typeof value !== 'string' ? JSON.stringify(value) : valuelocalStorage.setItem(key, saving)}const remove = () => localStorage.removeItem(key)return {get,set,remove,}}
This example expands the functionality by offering an optional json
option.
The Todo app above needed to serialize the complex objects to JSON to save them.
But that's not always the case. If the user wants to just save a regular string
then the JSON serialization is not necessary. This option lets the user toggle
the JSON parsing.
This works by encapsulating the key
passed in and creating three functions.
These functions are called by the user. They use a JavaScript closure to “hold
onto” the key value the user originally passed in. This means the user doesn't
need to pass the key each time - they can just pass the important value.
const set = (value: T) => {const saving = typeof value !== 'string' ? JSON.stringify(value) : valuelocalStorage.setItem(key, saving)}return { set }
This allows them to use the API in the following way:
const { get, set } = useStorage(`todo-list-${name}`, { json: true })// gets the saved todos, or an empty array if none foundconst [todos, setTodos] = useState<Todo[]>(get() || [])const onAddTodo = (text: string) => {setTodos([...todos, { text }])// save to local storageset([...todos, { text }])}
Pretty awesome!
Now onto the next challenge, not loading it every time.
Using useEffect
to trigger once
The required logic is:
When the component loads for only the first time, load the saved todos.
This is accomplished in React using the useEffect
hook. You can read the
details about the hook on the official
docs. The usage here will be to
only run something once.
import { useEffect } from 'react'useEffect(() => {// This is the code to run}, [])
The first parameter is the function you want to run. The second parameter tells React when to run it. Since this usage is an empty array, it runs only when that array changes. The array never changes, so it only runs once when the component first loads.
Loading the to-dos looks like:
import { useEffect } from 'react'const TodoList = ({ name }: TodoListProps) => {const { get, set } = useStorage(`todo-list-${name}`, { json: true })// Always load empty array by defaultconst [todos, setTodos] = useState<Todo[]>([])// On first load, get the Todos from local storage. If none found, set to empty arrayuseEffect(() => {setTodos(get() || [])}, [])return (...)}
And now the loading code only fires when the component first loads! Combined with the above code for setting the Todos and things are starting to take shape.
Finally, to round this out, there's another challenge. And this requires talking about some tradeoffs.
Designing APIs
A large part of software engineering comes down to designing APIs. How do you
want your software to be used? In the above example, the local storage hook is
in a good place. It works well, it's simple and performant. It's easy to reason
about what each section is doing. Components can simply use the hook and not
need to have localStorage
used at all in their code.
However, it begs another question.
The TodoList
component is managing both the state and the local
storage.
Does it make more sense to merge those?
Instead of managing the state and saving to local storage, what if... the local storage was the state.
The first question is about performance. When moving the save trigger outside of the user control it would write on each save. Would that make the application lag? A little searching around on the Internet and it looks like you could save massive amounts of data in local storage before performance took a noticeable hit.
The application doesn't need to save and load from local storage on each
change. That's a redundant action. So the logic can stay the same as in the
component, but you can simply merge useState
into useStorage
.
This is a pretty different API though. The useStorage
hook above is very
generic. It can be used to quickly save any value. It has very little
complexity. By adding in state management too, the hook responsibility grows.
But if this is a common use case, it makes sense to build a hook that will do this. What's better, is this can be a new hook that uses both the others.
Merging the APIs
To merge the State and Storage API, you can create a new hook which does both.
import { useEffect, useState } from 'react'import { useStorage } from './storage-hook'export const useSavedState = <T,>(key: string,initial: T): [T, React.Dispatch<React.SetStateAction<T>>] => {const { get, set } = useStorage<T>(key, { json: true })const [state, setState] = useState<T>(get() || initial)useEffect(() => {set(state)}, [JSON.stringify(state)])return [state, setState]}
This uses heavy use of TypeScript generics, which allows for the types to all
remain correct. It also uses useEffect
to save the state each time the state
changes.
The final usage looks like:
const TodoList = ({ name }: TodoListProps) => {const [todos, setTodos] = useSavedState<Todo[]>(`todo-list-${name}`, [])return (...)}
Which is almost the same as the useState
(on purpose), with the addition of
which key to save the state to local storage whenever it changes.
Local Storage in React
That's how you can work with local storage in React! The APIs for localStorage
are very easy. A lot of it comes down to the API design and how you want to
split responsibilities. The first hook built only manages local storage. The
second also managed the state and automatically loaded or saved the state when
it changed. This gives your application a lot of flexibility depending on what
it needs to do.
Software engineering is often about API design and understanding tradeoffs. With a good API design, you can get the best of both!