Persisting your state can sometimes be difficult. Useful ways to do it are setting the value to localStorage
or sessionStorage
. But managing manual set and get operations on larger applications will become difficult. And what if you want to store your state on the server and sync it across devices? In that case you need even more hassle with fetch
calls, useEffects
and more. It becomes a great big hassle.
In this article we are going to discuss a better solution, which will not only scale well but provide us with features that aren’t available to us with just the local storage API, such as typesafety and automatic updates.
Follow the code on GitHub where I have posted all the code shown in this article and more.
Note on TypeScript and React
All the following code (with types removed) is also valid JavaScript and will also work in JavaScript. The implementation is also completely framework agnostic and will work with any framework, from vanilla JavaScript to Vue. The hook and React examples provided at the end of this article are however React-specific, but should be implementable in other frameworks as well to achieve similar results.
The idea
The target is to create an API for working with pieces of data which are stored in persistent stores (localStorage
, sessionStorage
, a server) which provides us all the following perks:
- Abstracts away the implementation of interacting with the store using the strategy pattern and thus allows for implementing other stores as well.
- Is typesafe, with all values being validated.
- Supports custom serialization and deserialization.
- Contains a subscribable which allows for subscribing to all state updates, which will further allow us to create React hooks which automatically update to match the state (or similar constructs in other frameworks).
-
Provides a simple API for interacting with the data with minimal functions
.get()
,.set(value)
,.update(prev => newValue)
,.clear()
and.subscribe(listener)
.
The solution is an abstraction, a function, createPersistentItem
that creates a single centralized object with an API to access any single piece of persistent data, be it a primitive or a complex object.
Moreover, the function should take as an argument a persistence strategy, which handles the actual setting, getting and clearing of the value in any persistent store, such as localStorage
or even a custom server.
Let’s start off by tackling persistence strategies
Pesistence strategies are an important part of the solution. They abstract away the implementation of actually storing the data somewhere, be it localStorage
, sessionStorage
, a database via an API or any other solution.
A persistence strategy should implement three methods: get
, set
and clear
for getting, setting and clearing an item for a given key with a value of a given type.
Additionally, some storage methods are synchronous, such as localStorage
and sessionStorage
and we might want to get the data synchronously from these stores when possible. To support this functionality, we add another method, getSync
which returns the value synchronously and a flag (supportsSync
), which tells the user whether the persistence strategy supports synchronous operations. Using getSync
for async stores (such as an API) will always return undefined
as they can not fetch the data synchronously.
From the above plan, we get the following interface for all persistence strategies.
// src/lib/PersistenceStrategy.ts
export interface IPersistenceStrategy {
supportsSync?: boolean;
get<T>(options: PersistenceStrategyGetOptions<T>): Promise<T | undefined>;
getSync<T>(options: PersistenceStrategyGetOptions<T>): T | undefined;
set<T>(options: PersistenceStrategySetOptions<T>): Promise<T>;
clear(key: string): Promise<void>;
}
Additionally, the PersistenceStrategyGetOptions
and PersistenceStrategySetOptions
are defined below. They include a key to identify where the value is stored. For getting the value, we also provide methods for validating the value and optionally deserializing the value (else we use JSON.parse
). For setting the value respectively, we provide the value we want to set and optionally a function to serialize it when JSON.stringify
will not do.
// src/lib/PersistenceStrategy.ts
export type PersistenceStrategyGetOptions<T> = {
key: string;
validate: (t: any) => t is T;
deserialize?: (serial: string) => T | undefined;
};
export type PersistenceStrategySetOptions<T> = {
key: string;
value: T;
serialize?: (t: T) => string;
};
Following these guidelines, we can create an example strategy for storing items in localStorage
.
// src/lib/LocalStoragePersistenceStrategy.ts
export const LocalStoragePersistenceStrategy: IPersistenceStrategy = {
// Local storage supports synchronous operations
supportsSync: true,
// Local storage synchronous getter
getSync<T>(opts: PersistenceStrategyGetOptions<T>): T | undefined {
try {
// Get serial value from local storage, if not found return undefiend
const serial = localStorage.getItem(opts.key);
if (!serial) return undefined;
// Deserialize with deserializer or JSON.parse if no deserializer,
// return undefined if serialization fails
const value = opts.deserialize
? opts.deserialize(serial)
: JSON.parse(serial);
if (!value) return undefined;
// Validate, return value or undefined on invalid validation
if (opts.validate(value)) return value;
return undefined;
} catch (e) {
// On all errors return undefined
console.error(e);
return undefined;
}
},
// Async getter simply promisifies the sync getter method
async get<T>(opts: PersistenceStrategyGetOptions<T>): Promise<T | undefined> {
return this.getSync(opts);
},
async set<T>(opts: PersistenceStrategySetOptions<T>): Promise<T> {
// Serialize with serializer or JSON.stringify and save to localStorage
const serial = opts.serialize
? opts.serialize(opts.value)
: JSON.stringify(opts.value);
localStorage.setItem(opts.key, serial);
return opts.value;
},
// Clear value
async clear(key: string): Promise<void> {
localStorage.removeItem(key);
},
};
Other persistence strategies
- The
SessionStoragePersistenceStrategy
(view code on GitHub) is an exact copy of theLocalStoragePersistenceStrategy
, but withlocalStorage
swapper forsessionStorage
. - The
ServerPersistenceStrategy
(view code on GitHub) is an example persistence strategy for interacting with the simplest possible API for storing items on the server. UnlikeLocalStoragePersistenceStrategy
, it is async only and calling thegetSync
method will always result in undefined. (View the simplest implementation of an express server which handles the requests fromServerPersistenceStrategy
)
Implementing createPersistentItem
Now that we have tackled persistence strategies and our persistent items no longer need to worry about the details of how they will be fetched, updated and cleared, we can continue on with implementing persistent items. The first step is to define an interface for persistent items, which contains three parts.
- Data getting methods
-
get()
for asynchronously getting the value of the item. -
getSync()
for synchronously getting the value of the item if the persistence strategy supports synchronous operations (whenpersistenceStrategy.supportsSync
istrue
). -
subscribe(listener)
which subscribes to all state updates and returns an unsubscriber function.
-
- Date updating methods
-
set(value)
for setting the value. Returns the new value. -
update(updater)
for updating the value with an updater function when the next value depends on the previous value. Returns the new value. -
clear()
for clearing the value.
-
- Options for the persistent item. These are also the options that are provided when creating a persistent item.
-
key
for storing the persistent item. -
persistenceStrategy
for storing the item in a store. -
validate
for validating that a value is a valid value for this item. -
serialize
for optionally overridingJSON.stringify
serialization. -
deserialize
for optionally overridingJSON.parse
deserialization.
-
The final interface will look like the following.
// src/lib/createPersistentItem.ts
export interface IPersistentItem<T> {
// Interfaces for getting data (sync / async) and subscribing to data updates
get(): Promise<T | undefined>;
getSync(): T | undefined;
subscribe(listener: (t: T | undefined) => void): () => void;
// Interfaces for updating data (set value, update value with updater function
// or clear value)
set(value: T): Promise<T>;
update(updater: (t: T | undefined) => T): Promise<T>;
clear(): Promise<void>;
// Options
key: string;
persistenceStrategy: IPersistenceStrategy;
validate: (t: any) => t is T;
serialize?: (t: T) => string;
deserialize?: (string: string) => T | undefined;
}
Let’s start implementing this. First we’ll define the function signature (options and return type). The function will return a IPersistentItem<T>
as defined previously and take as options the previously discussed options and directly reveal them in the return value.
// src/lib/createPersistentItem.ts
export function createPersistentItem<T>(options: {
key: string;
validate: (t: any) => t is T;
persistenceStrategy: IPersistenceStrategy;
serialize?: (t: T) => string;
deserialize?: (string: string) => T | undefined;
}): IPersistentItem<T> {
/* ... */
return {
// Reveal the options
...options,
/* ... Implement rest of the methods here ... */
}
}
Let’s next implement each method. First, the getter methods get
and getSync
are simple calls to the persistenceStrategy.get
and persistenceStrategy.getSync
methods respectively.
// src/lib/createPersistentItem.ts
export function createPersistentItem<T>(/* ... */): IPersistentItem<T> {
/* .. */
return {
...options,
getSync() {
return options.persistenceStrategy.getSync({
key: options.key,
validate: options.validate,
deserialize: options.deserialize
});
},
get() {
return options.persistenceStrategy.get({
key: options.key,
validate: options.validate,
deserialize: options.deserialize
});
},
}
}
Next up lets implement the subscribe
method. For that use, we are going to need an internal subscribable
, created with a createSubscribable
method (see here for implementation). That subscribable is going to receive updates from all data updating methods (set
, update
and clear
) and subscribe
is going to only function as a wrapper for subscribable.subscribe
. After implementing createSubscribable
, the implementation is as easy as below.
// src/lib/createPersistentItem.ts
export function createPersistentItem<T>(/* ... */): IPersistentItem<T> {
const subscribable = createSubscribable<T | undefined>();
return {
...options,
getSync() { /* ... */ },
get() { /* ... */ },
subscribe: subscribable.subscribe,
}
}
This allows us to finally implement all data updating methods. Each of the methods is primarily a call to either the persistenceStrategy.set
or persistenceStrategy.clear
methods. Additionally, the methods handle errors, publish the new value to the subscribable
after successfully updating the value and return the new value. In addition, update
also fetches the previous value and uses it to call the provided updater
function to derive the new value.
// src/lib/createPersistentItem.ts
export function createPersistentItem<T>(/* ... */): IPersistentItem<T> {
const subscribable = createSubscribable<T | undefined>();
return {
...options,
getSync() { /* ... */ },
get() { /* ... */ },
subscribe: subscribable.subscribe,
async set(value: T) {
try {
await options.persistenceStrategy.set({
value,
key: options.key,
serialize: options.serialize,
});
subscribable.publish(value);
} catch (e) {
console.error(e);
} finally {
return value;
}
},
async update(updater: (prev: T | undefined) => T) {
const prev = await this.get();
const value = updater(prev);
try {
await options.persistenceStrategy.set({
value,
key: options.key,
serialize: options.serialize,
});
subscribable.publish(value);
} catch (e) {
console.error(e);
} finally {
return value;
}
},
async clear() {
try {
await options.persistenceStrategy.clear(options.key);
subscribable.publish(undefined);
} catch (e) {
console.error(e);
}
},
}
}
There we have it! A wrapper for persistent items!
Using persistent items
We can now use the API for example for storing the number of clicks to a button as follows.
const nClicks = createPersistentItem<number>({
key: "clicks",
validate: (t: any): t is number => typeof t === "number" && t >= 0,
persistenceStrategy: LocalStoragePersistenceStrategy
})
// Setting the value to a number from an input
document.querySelector("button#set").addEventListener("click", () => {
nClicks.set(document.querySelector("input#num").valueAsNumber);
});
// Updating the value
document.querySelector("button#add").addEventListener("click", () => {
nClicks.update(prev => (prev ?? 0) + 1);
});
// Resetting the value
document.querySelector("button#reset").addEventListener("click", () => {
nClicks.clear();
});
// Logging each new value to the console
nClicks.subscribe(newValue => console.log(newValue));
Creating a React hook
For easier use with React, we can also create a custom hook for accessing the current value of a persistent item. The hook will allow us to automatically rerender a component whenever the persistent item’s value is updated from anywhere within the app.
The hook contains takes as input a persistent item and contains its value in a useState
. The state is initialized with the getSync
method, which will automatically be undefined
for async items. For async items, another useEffect
is fired once which asynchronously initializes the state from undefined. Finally another useEffect
is fired which handles subscribing and unsubscribing to state updates.
// src/hooks/usePersistentItem.ts
export function usePersistentItem<T>(item: IPersistentItem<T>) {
// State for holding current value, automatically updated. Initialize with
// synchronously gotten value (undefined for async persistence strategies).
const [value, setValue] = useState<T | undefined>(item.getSync());
// Initialize value asynchronously for async persistence strategies
useEffect(() => {
if (!item.persistenceStrategy.supportsSync) {
item.get().then((_) => setValue(_));
}
}, [setValue, item]);
// Subscribe to updates and auto-update state
useEffect(() => item.subscribe((t) => setValue(t)), [setValue, item]);
// Return current value
return value;
}
The following snippet demonstrates the usage of the hook. The beautiful thing about this centralized implementation with a subscribable
is that clicking one button automatically increments the value in both buttons as they are subscribed to the same item. The value is stored in local storage, but changing it to be stored in session storage, on a server or somewhere else is as easy as changing the persistence strategy.
// src/App.tsx
function Counter(props: { item: IPersistentItem<number>, label: string }) {
const clicks = usePersistentItem(props.item);
return <div>
<p>
{props.label}
</p>
<button onClick={() => props.item.update(current => (current ?? 0) + 1)}>
Clicked {clicks ?? 0} times
</button>
<button onClick={() => props.item.set(5)}>
Set to 5
</button>
<button onClick={() => props.item.clear()}>
Reset
</button>
</div>
}
function App() {
return (
<div>
<Counter item={nClicks} label="Local storage 1" />
<Counter item={nClicks} label="Local storage 2" />
</div>
);
}
const nClicks = createPersistentItem<number>({
key: "clicks",
validate: (t: any): t is number => typeof t === "number" && t >= 0,
persistenceStrategy: LocalStoragePersistenceStrategy
});
Final words
Thank you for reading. You are free to use the pattern provided in this article as well as any code you find here. Please feel free to send me a message if you find this useful or find a way to make this even better.
Ideas on how to make this better
- Reducing boilerplate for persistence strategies
Currently writing persistence strategies creates some boilerplate in our code. In each persistence strategy, we must declare error handling, serialization, deserialization and validation manually. However, you could easily create another abstraction (createAsyncPersistenceStrategy
and createSyncPersistenceStrategy
) which could be used as follows:
// Example API
export const LocalStoragePersistenceStrategy = createSyncPersistenceStrategy({
get(key: string): string | undefined {
return localStorage.getItem(key) ?? undefined;
},
set(key: string, serial: string): void {
localStorage.setItem(key, serial);
},
clear(key: string): void {
localStorage.removeItem(key);
}
});
Here createSyncPersistenceStrategy
and createAsyncPersistenceStrategy
simply take the implementations of fetching a serialized value, setting a serialized value and clearing the value and wrap them with validation, serialization, deserialization, correct get
and getSync
and supportsSync
implementations and error handling. The implementations can be found in GitHub, together with example usage of them for creating LocalStoragePersistenceStrategy
and ServerPersistenceStrategy
with the functions.
Top comments (0)