Skip to main content

The only two custom React hooks we ever really use

ยท 11 min read

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...

  1. Rendering the current state
  2. 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.

  1. 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.

  2. You can pass promises to both setState and extendState 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 to extendState. The state will eventually be extended by the resolved value.

    extendState(fetchPartialState())

    Note: The setState and extendState functions accept promises, not functions. In other words, the usage is setState(asyncFunc()), not setState(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, as usePromise 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 the useAsyncExtendedState hook, you probably don't care about readThingRequest.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:

src/API/resource/thing.ts
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:

src/API/resource/types.ts
/**
* 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:

src/API/resource/thing/types.ts
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

Created thing: 12/17/2021, 12:32:13 PM
Updated thing: 12/17/2021, 12:32:13 PM

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 molecule-app and molecule-api. Thorough documentation and guides are included.

git clone https://github.com/molecule-dev/molecule-app.git
cd molecule-app
npm install
git clone https://github.com/molecule-dev/molecule-api.git
cd molecule-api
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!