Photo by Brook Anderson on Unsplash

Recently, I have written about fetching data using Hooks and Render Props. The purpose of that article was to fetch and display data. In this post, I would like to revisit that article and extend it with examples to show how to use the same concept to perform other types of actions (e.g POSTing data).

How do we abstract away data actions?

Whether you are fetching, updating, adding, deleting, or performing custom actions, all these operations have one thing in common. They all go through three stages— loading, success/data, and error. These three stages are building blocks of UI development. Any operation we do (it doesn’t even need to be an API action), it will have these three states.

Now, let’s think about the interface. Our interface accepts an asynchronous action (in our case, it is a function) and return three states with a way to perform the action.

I want to mention, just using a hook or a component is not enough. The actions must respect the provided interface; so that, our logic can work normally. This is one of the important things to remember when abstracting any kind of logic. We should set up interface/contract that all parts of the abstracted idea agree upon.

Using hooks to build async action functionality

Hooks provide a way to integrate custom, reusable functionality into our components. We used to reuse components for view logic and now we can also reuse business logic using hooks. Before writing the implementation, let’s show how we are going to use it:

import React from 'react';

import { useAction } from './hooks';

import { saveProfile } from './api'; function App() {

// The second element "perform" is just a variable using array destructor

const [state, perform] = useAction(saveProfile); // We send the data as an argument into the perform function,

// which in our case === "updateProfile"

async function handleSuccess(e) {

e.preventDefault();

await perform({ age: "12456" });

} async function handleError(e) {

e.preventDefault();

await perform({ age: "test" });

} // Helper function to action result coming from the hook

// The tested values in this function are the

// values returned from hook function

function renderStatus() {

if (state.loading) return "Loading..."; if (state.error) return `Error: ${state.error.message}`; return `Data: ${JSON.stringify(state.data)}`;

} return (

<div className="App">

<button onClick={handleSuccess}>Success!</button>

<button onClick={handleError}>Error!</button>

<div className="status">{renderStatus()}</div>

</div>

);

}

Now onto the implementation:

// hooks.js import { useState } from 'react'; export const useAction = (action) => {

const [loading, setLoading] = useState(false);

const [data, setData] = useState(null);

const [error, setError] = useState(null); // The incoming "action" argument to the hook is NOT performed.

// It is only stored in the function scope; so that, we can use it when

// performing the action using the following function

// This function is returned as the second element in the returned array

const performAction = async (body = null) => {

try {

setLoading(true);

setData(null);

setError(null);

const data = await action(body);

setData(data);

} catch (e) {

setError(e);

} finally {

setLoading(false);

}

} return [{ loading, data, error }, performAction];

}

This simple hook gives us current state action to be performed and a function to perform the action. The body argument is in performAction function because non GET requests typically have a request body. In the above example, if we call the perform function, loading, data, and error states will be set “by the hook”. Then, we can easily use these states to do our view logic (show loading bar, show error, or show that the operation succeeded).

As you can see, we are using try/catch/finally blocks to decide whether we received error or success. The try catch used inside these blocks are not the “real” try catch that we know from programming. They are promise resolve/reject methods disguised in an async block. I have mentioned earlier that, actions must agree on the interface provided by the hook to unify and simplify the developer experience. In our case, the interface is that success data must be returned and errors must be thrown. Here is an example API call code:

// api.js export const updateProfile = async (body) => {

const response = await fetch(SOME_URL_HERE, {

method: 'PUT',

body,

headers: {

'Content-Type': 'application/json'

}

} const data = await response.json();

if (!response.ok) {

throw new Error(data);

} return data;

}

Typically, fetch throws errors for non API related errors (network down, JSON validation error etc). However, we have also thrown errors for for our API logic (bad request, REST 404 etc). This allow us to show handle errors in a similar way (store it in the state) for all errors coming from the API and make it easier to think of our action logic. If you want to distinguish fetch errors from API errors, you can create a separate error class (e.g ApiError ) and throw that instead of generic JS Error .

That’s it. Now, you can perform asynchronous data related actions using a simple yet a powerful interface thanks to Hooks!

Demo

I have added a simple demo to show how it works. The only difference between the demo and the code samples is that, instead of doing a real fetch request, I have imitated an API request.

CodeSandbox Demo

Conclusion

The point of this post was to generalize data fetching to allow performing any kind of asynchronous action. Custom hooks make it very easy to build custom and reusable functionality that can be attached to any component. The non-conflicting behavior gives us a safe way to separate business logic from view logic without needing to create a component.

EDIT: Rewrote code examples in the post; so that, it is clear what is going on. Added CodeSandbox Demo. Fixed some grammar issues. Clarified some texts.

EDIT 2: Fixed hook logic where errors were not cleared on every perform function.