If I've learned anything from being glued to a code editor for (probably too many) years, it's that the simplest approach is almost always the best one.
In the case of modern front end engineering and React especially, you can reduce everything down to two simple concepts...
- Rendering the current state
- Updating the state
Realistically, it's a bit more complex than that. You'll often need to consider shared application state and internal component state. Updates can also occur asynchronously with potentially many different states along the way, possibly arriving at an unexpected state when there's an error or if the user aborts the asynchronous operation. You may also need to consider performance when rendering lots of data or animating complex scenes.
But when it all comes down to it, everything is just a function of state.
With that in mind, we have a couple of super simple React hooks that we use everywhere. Combined with a design pattern which we'll cover later in this blog post, it's hard to imagine needing much more for the vast majority of cases. We still use basic hooks like useState
, useEffect
, useMemo
, and useRef
as necessary, but as far as complex state management goes, the following two custom hooks are usually all we need.
useAsyncExtendedState
โ
This hook works almost exactly like React's built-in useState
hook, but with two additions. It's probably easiest to understand by example.
It includes an extra method to extend the current state.
Define the
State
interface.interface State {
foo: string
bar: string
}Use the hook.
const [ state, setState, extendState ] = useAsyncExtendedState<State>({
foo: `foo`,
bar: `bar`
})Setting state works as usual.
setState({ foo: `Hello`, bar: `World!` })
setState(state => ({ foo: `Hello`, bar: `World!` }))Or you can extend the state.
// Set `foo` while keeping `bar`.
extendState({ foo: `Hello` })
// Set `foo` while keeping `bar`, as a function of the current state.
extendState(state => ({ foo: state.foo.toUpperCase() }))Try not to abuse
extendState
. It exists to make your code more concise and readable when you're merging partial states many times throughout your codebase.It should become clear later in this article why we've decided to include
extendState
. (The API returns partial states for certain endpoints commonly used for every resource.)tip
With Molecule.dev, we try to give you as much control over your Molecule's code as possible. All of this code is open source, so you can quickly change it to suit your needs and preferences.
You can pass promises to both
setState
andextendState
to asynchronously update the state.Suppose you have a function which asynchronously fetches data from your API and returns the data as the next state.
const fetchState = () => API.client.get<State>(`data`).then(response => {
return response.data
})Alternatively, the promise may also resolve a function of the current state, similar to
setState(state => ({ ...state }))
.const fetchState = () => API.client.get<State>(`data`).then(response => {
return (state: State) => {
return response.data
}
})Call the asynchronous function and pass the promise to
setState
. The state will eventually be set to the resolved value.setState(fetchState())
Similarly for
extendState
, suppose you have a function which asynchronously fetches partial data from your API.const fetchPartialState = () => API.client.get<Partial<State>>(`data`).then(response => {
return response.data
})
// Or a function of the current state
const fetchPartialState = () => API.client.get<Partial<State>>(`data`).then(response => {
return (state: State) => {
return response.data
}
})Call
fetchPartialState
and pass the promise toextendState
. The state will eventually be extended by the resolved value.extendState(fetchPartialState())
Note: The
setState
andextendState
functions accept promises, not functions. In other words, the usage issetState(asyncFunc())
, notsetState(asyncFunc)
. See this issue for more information.
usePromise
โ
More often than not, when something is happening asynchronously, you'll want to display some feedback to the user.
This hook accepts any asynchronous function (i.e., a function which returns a promise) and returns a new asynchronous function wrapping the original, along with the state of the promise plus cancel
and reset
methods.
For example, suppose we want to fetch data from the API and display the current state of the request to the user.
Define the State
interface.
interface State {
foo: string
bar: string
}
Define a function which returns a promise. In this example, we'll fetch API data and resolve it as State
.
const read = (id: string) => API.client.get<State>(`things/${id}`).then(response => {
return response.data
})
Pass the function to the usePromise
hook.
const [ readThingRequest, readThing ] = usePromise(read)
readThingRequest
contains the state of the promise plus a reset
method, defined below:
interface PromiseState<T> {
status?: `pending` | `resolved` | `rejected`
promise?: Promise<T>
value?: Awaited<T>
error?: Error
cancel?: (message?: string) => void
}
type PromiseStateWithReset<T> = PromiseState<T> & {
reset: (keys?: Array<keyof PromiseState<T>>) => void
}
If you would like to initialize the readThingRequest
state, pass the initial state as the second argument:
const [ readThingRequest, readThing ] = usePromise(read, {
status: `resolved`,
value: state
})
readThing
has the same function signature as read
. In other words, you'll call readThing(id)
the same way you'd call read(id)
.
Initially, before readThing
is called, the readThingRequest
will be an empty object (other than the reset
method) if an initial state was unprovided.
When you call readThing(id)
, the readThingRequest
state becomes:
const readThingRequest = {
status: `pending`,
promise, // the promise returned by `read(id)`
cancel, // a method to cancel the asychronous update
reset
}
When the promise has resolved, the readThingRequest
state then becomes:
const readThingRequest = {
status: `resolved`,
value, // the resolved value
reset
}
If there is an error and the promise is rejected:
const readThingRequest = {
status: `rejected`,
error, // an instance of `Error`
reset
}
If readThingRequest.cancel
was called before the promise resolves, the readThingRequest
state immediately becomes:
const readThingRequest = {
status: `rejected`,
error, // an instance of `Error` with the message passed to `readThingRequest.cancel`, if any
reset
}
To cancel updates for the current promise, with an error message:
readThingRequest.cancel('Cancelled!')
To cancel updates for the current promise, without an error message:
readThingRequest.cancel()
Note: Cancelling only prevents the state update. The asynchronous function you've called will continue unless you have a way to stop it. For example, in our case where we're using
axios
for API requests, we would need to incorporate its cancellation methods. This is beyond the scope of this article though, asusePromise
can be used for any asynchronous operation.
To reset the state entirely:
readThingRequest.reset()
Or if you want to reset a specific value (readThingRequest.error
, for example):
readThingRequest.reset(`error`)
Or multiple (both readThingRequest.status
and readThingRequest.error
, for example):
readThingRequest.reset([`status`, `error`])
You may also call readThing(id)
more than once, and in which case, the current readThingRequest.value
will remain until it is overridden by the next resolved value. So if you would like to reset the value when refetching:
readThingRequest.reset([`value`])
readThing(id)
Note: If you're combining
readThing
with theuseAsyncExtendedState
hook, you probably don't care aboutreadThingRequest.value
. More on that below.
Combining the two hooksโ
The usePromise
hook pairs especially well with the useAsyncExtendedState
hook.
At the start of this post, I mentioned a design pattern which makes everything clean and predictable. We've been using it in the example(s) above, but now we'll make it more obvious.
API resources typically have a handful of their own routes, usually following a RESTful design with CRUD (create, read, update, and delete) methods.
Molecule.dev's core application code is structured such that each API resource has its own directory with an index which exports methods for each route.
Suppose we have an API resource called a thing
with CRUD methods:
import { client } from '../../client'
import * as types from './types'
export const create = (props: types.CreateProps): Promise<types.SuccessResponse> => (
client.post(`things`, props)
)
export const read = (id: string): Promise<types.SuccessResponse> => (
client.get(`things/${id}`)
)
export const update = (id: string, props: types.UpdateProps): Promise<types.SuccessPartialResponse> => (
client.patch(`things/${id}`, props)
)
export const del = (id: string): Promise<types.SuccessPartialResponse> => (
client.delete(`things/${id}`)
)
We'll import these methods and use them with the useAsyncExtendedState
and usePromise
hooks.
As an example, let's create a component to read some thing
by id and allow the user to update it.
First, let's cover some relevant type definitions.
Every RESTful API resource will have an id
, createdAt
date, and updatedAt
date:
/**
* The resource's properties.
*/
export interface Props {
/**
* Usually a UUID.
*/
id: string
/**
* When the resource was created.
*
* Usually an ISO 8601 timestamp.
*/
createdAt: string
/**
* When the resource was last updated.
*
* Usually an ISO 8601 timestamp.
*/
updatedAt: string
}
Our thing
resource will have a description
property:
import * as resourceTypes from '../types'
/**
* The thing's properties returned by the API.
*/
export interface Props extends resourceTypes.Props {
/**
* The thing description.
*/
description?: string
}
tip
We've also defined types for every API request and response, which you can check out on GitHub.
For the sake of example, we'll create an Editor
component with a predefined initial state and render the current state with an input for updating the description
. An "Update thing" button will request an API update when clicked, extending the state with the response data, and we'll render a cancel button along with the current request status and error, if defined.
import React from 'react'
import { useAsyncExtendedState, usePromise } from '../../hooks'
import { update } from '../../API/resource/thing'
import { types } from '../../API/resource/thing'
export const Editor = () => {
const [ state, setState, extendState ] = useAsyncExtendedState<types.Props>({
id: `733e26aa-97ea-46d7-b4d4-e556a5f37d68`,
createdAt: `2021-12-17T12:32:13.981Z`,
updatedAt: `2021-12-17T12:32:13.981Z`,
description: `This is a simple but powerful design pattern!`
})
const [ updateThingRequest, updateThing ] = usePromise((updateProps: types.UpdateProps) => (
update(state.id, updateProps).then(response => response.data.props)
))
return (
<div>
<div>
{`Created thing: ${new Date(state.createdAt).toLocaleString()}`}
</div>
<div>
{`Updated thing: ${new Date(state.updatedAt).toLocaleString()}`}
</div>
<textarea
value={state.description}
onChange={event => extendState({ description: event.target.value })}
/>
<button onClick={() => extendState(updateThing({ description: state.description }))}>
{updateThingRequest.status === `pending` ? `Updating thing...` : `Update thing`}
</button>
{updateThingRequest.cancel && (
<button onClick={() => updateThingRequest.cancel(`cancelled`)}>
Cancel update
</button>
)}
{updateThingRequest.status && (
<div>
{`Request status: ${updateThingRequest.status}`}
</div>
)}
{updateThingRequest.error && (
<div style={{ color: `red` }}>
{`Error: ${updateThingRequest.error.message}`}
</div>
)}
</div>
)
}
In case you missed it, the concise bit of code combining the two hooks is found on the button's
onClick
handler:extendState(updateThing({ description }))
See it in actionโ
This example is also available to play with on CodeSandbox.
Check out the sourceโ
You can find the code for these hooks on our GitHub here.
If you want to get started on an app and API using these design patterns, git clone the molecule
repository. Thorough documentation and guides are included.
git clone https://github.com/molecule-dev/molecule.git
cd molecule
npm install
Conclusionโ
You can combine these two hooks to cleanly manage asynchronous state at any level throughout your app, for nearly anything you can think of.
These design patterns help you simplify your thinking and the code itself by separating concerns in a way that is predictably consistent and incredibly easy to manage and build upon. This naturally leads to both a better user experience and a better developer experience.
Define asynchronous functions on their own.
Asynchronously set or extend state with a one-liner, via the
useAsyncExtendedState
hook.To show the user the current state of any asynchronous operation, pass the async function to the
usePromise
hook and render the promise state.
We use this for everything throughout applications built with Molecule.dev - signing up, logging in, the users themselves, enabling 2FA, payments, subscriptions, plan changes, device management, push notifications... everything!
If you like this and want to learn more, check out our GitHub and get started building something of your own by cloning Molecule.dev's core TypeScript app and API. After installing Node dependencies, you'll be greeted with documentation generated by TypeDoc where you can dig into more internals and play around.
If you're a professional web developer, where you're an indie dev or a CTO, and you want to save months of development time on cross-platform apps, visit Molecule.dev to assemble a codebase tailored to your specific needs. You're guaranteed a rock solid foundation from which you and your team can scale with ease.
Also be sure to follow us on Twitter @molecule_dev for regular updates and more posts like this one!