Handle logic at the right place in React

Published on

Deciding where to put logic code in React applications is crucial to keep a codebase clean and maintainable, and even functional. Rethinking the place of the logic to go from top to bottom can help a lot, and it's the main purpose of XState.


Recently, with a friend of mine, we wanted to let a React component know when some work was done in one of its child components. We were passing down a callback function, called inside a useEffect, when we got a response from a useResponse hook.

tsx
import React, { useEffect } from "react";
import { useResponse } from "./useResponse";
import { sendResponseToServer } from "./store";
 
interface ChildProps {
onResponseChange: (response: Response) => void;
}
 
function Child({ onResponseChange }: ChildProps) {
// When `load` function is called, a reponse arrives some time after.
const [response, load] = useResponse();
 
// This effect is run when `response` or `onResponseChanges` changes.
useEffect(() => {
if (response !== null) {
onResponseChange(response);
}
}, [response, onResponseChange]);
 
return (
<button onClick={load}>Load</button>
);
}
 
export default function App() {
// This function will be redeclared during each render.
async function handleResponseChange(response: Response) {
console.log("received response", response);
 
await sendResponseToServer(response);
}
 
return (
<main>
<Child onResponseChange={handleResponseChange} />
</main>
);
}

The issue is that handleResponseChange function was recreated for each render, and the useEffect was triggered each time, as the function is included in its list of dependencies. This is not really what we wanted to achieve.

permalinkuseCallback

Instead of declaring a new function for each render in the parent component, we can memoize it with useCallback hook.

tsx
import React, { useEffect, useCallback } from "react";
import { useResponse } from "./useResponse";
import { sendResponseToServer } from "./store";
 
interface ChildProps {
onResponseChange: (response: Response) => void;
}
 
function Child({ onResponseChange }: ChildProps) {
// When `load` function is called, a reponse arrives some time after.
const [response, load] = useResponse();
 
// This effect is run when `response` or `onResponseChanges` changes.
useEffect(() => {
if (response !== null) {
onResponseChange(response);
}
}, [response, onResponseChange]);
 
return (
<button onClick={load}>Load</button>
);
}
 
export default function App() {
// This function will be declared once.
const handleResponseChange = useCallback(async (response: Response) => {
console.log("received response", response);
 
await sendResponseToServer(response);
}, []);
 
return (
<main>
<Child onResponseChange={handleResponseChange} />
</main>
);
}

As the list of dependencies of useCallback is empty, the function will be created once, during the first render. But the <Child /> component absolutely does not know about that, and must assume its effect will not be triggered extraneous times. The code is still fragile as if handleResponseChange needs to depend on other values, the function would be re-declared when they change, and we would no longer control when the effect is run. It’s time for stronger solutions!

permalinkCombinatorial machine

I’m a big fan of XState, so we are going to use it now. If you are not familiar with it, check it out!

tsx
import React, { useEffect, useCallback } from "react";
import { createMachine } from 'xstate';
import { useInterpret } from '@xstate/react';
import { useResponse } from "./useResponse";
import { sendResponseToServer } from "./store";
 
interface ChildProps {
onResponseChange: (response: Response) => void;
}
 
function Child({ onResponseChange }: ChildProps) {
const [response, load] = useResponse();
 
const service = useInterpret(() => {
return createMachine({
schema: {
events: {} as {
type: 'Received response';
response: Response | null;
}
},
on: {
'Received response': {
cond: 'Is response defined',
 
actions: 'Forward response',
},
},
});
}, {
guards: {
'Is response defined': (_context, event) => event.response !== null,
},
// `actions` provided to `useInterpret` in its second parameter
// will be updated in the service each time they change in the React component.
//
// It means that this action will always reference the most recent value
// of `onResponseChange` and never become stale, but it will be called
// only when a new response is received.
actions: {
'Forward response': (_context, event) => {
if (event.response === null) {
throw new Error('Response is null');
}
 
onResponseChange(event.response);
},
},
});
 
// `service` is guaranteed to be stable between renders.
// The effect will only run when important dependencies are going to change.
useEffect(() => {
service.send({
type: 'Received response',
response,
});
}, [response, service]);
 
return (
<button onClick={load}>Load</button>
);
}
 
export default function App() {
// This is no longer an issue if the function is re-declared
// for each render.
async function handleResponseChange(response: Response) {
console.log("received response", response);
 
await sendResponseToServer(response);
}
 
return (
<main>
<Child onResponseChange={handleResponseChange} />
</main>
);
}

In <Child /> component we create a state machine that waits for one event: Received response. When the machine receives it, it checks if the response the event contains is defined and if it is, it calls onResponseChange.

Actions provided to useInterpret in its second parameter will be updated in the service each time they change in the React component. There will never be stale references to onResponseChange. This is no longer an issue if the function we depend on is re-declared on each render!

We still use a useEffect, in which we send an event to the service of the machine. The service is stable between renders, so only things we really want to depend on will trigger the effect.

Note that, indeed, a state machine can have no states except the implicit root state. This is called a combinatorial machine and is supported by XState since a year. I rarely found practical use cases for it, but being able to act as a proxy is one of its main advantages.

permalinkThink from top to bottom: with plain React

Until now, we wrote what David Khourshid calls bottom-up code, that is, logic code spread low in components tree, directly in events handlers, making the code hard to understand and then to maintain.

The real issue is that our <Child /> component should not handle that part of logic itself. The parent component should centralize the logic and only let its child component determine how load function is triggered.

tsx
import React, { useEffect } from "react";
import { useResponse } from "./useResponse";
import { sendResponseToServer } from "./store";
 
interface ChildProps {
onLoadButtonClick: () => void;
}
 
// The Child component does not contain logic anymore.
function Child({ onLoadButtonClick }: ChildProps) {
return (
<button onClick={onLoadButtonClick}>Load</button>
);
}
 
export default function App() {
const [response, load] = useResponse();
 
useEffect(() => {
// We declare the function inside the `useEffect` so that
// the effect only depends on the response.
//
// The risk of referencing stale values is reduced but the effect would
// still re-run if it needs to depend on other values than `response`.
async function handleResponseChange(response: Response) {
console.log("received response", response);
 
await sendResponseToServer(response);
}
 
if (response !== null) {
handleResponseChange(response);
}
}, [response]);
 
return (
<main>
<Child onLoadButtonClick={load} />
</main>
);
}

This is a better solution, and it feels less hacky than the combinatorial machine. However, the code is still fragile and relies on implicit behaviors. What does happen if the value of response changes before the async function sendResponseToServer finishes? The effect would be triggered again, and handleResponseChange would be called again but this time with the new response. This may be wanted, but if not, this would be difficult to avoid without tricks based on useRef.

Let’s do a last refactor, and use XState again.

permalinkFrom top to bottom: with XState

With XState, we can model all the logic inside a state machine where only things that are defined can happen.

tsx
import React, { useEffect } from "react";
import { createMachine, assign } from "xstate";
import { useMachine } from "@xstate/react";
import { useResponse } from "./useResponse";
import { sendResponseToServer } from "./store";
 
interface ChildProps {
onLoadButtonClick: () => void;
}
 
// The Child component does not contain logic.
function Child({ onLoadButtonClick }: ChildProps) {
return (
<button onClick={onLoadButtonClick}>Load</button>
);
}
 
export default function App() {
const [response, load] = useResponse();
 
const [state, send] = useMachine(() => {
return createMachine({
schema: {
context: {} as {
response: Response | undefined;
},
events: {} as {
type: 'Received response';
response: Response | null;
},
},
context: {
response: undefined
},
initial: 'Waiting for response',
states: {
'Waiting for response': {
on: {
'Received response': {
cond: 'Is response defined',
 
target: 'Sending response to server',
 
actions: 'Assign response to context',
},
},
},
'Sending response to server': {
invoke: {
src: 'Send response to server',
 
onDone: {
target: 'Waiting for response',
},
},
// Because we listen to `Received response` event here,
// we explicitly allow to make a new request to the server
// while the last one has not finished yet.
on: {
'Received response': {
cond: 'Is response defined',
 
target: 'Sending response to server',
 
actions: 'Assign response to context',
},
},
},
},
});
}, {
guards: {
'Is response defined': (_context, event) => event.response !== null,
},
actions: {
'Assign response to context': assign({
response: (_context, event) => {
if (event.response === null) {
throw new Error('Response is null');
}
 
return event.response;
},
}),
},
services: {
'Send response to server': async ({ response }) => {
if (response === undefined) {
throw new Error('Response must have been stored into context');
}
 
console.log("received response", response);
 
await sendResponseToServer(response);
},
},
});
 
// Synchronize the machine with the hook.
// `send` function is stable between renders.
useEffect(() => {
send({
type: 'Received response',
response,
});
}, [response, send]);
 
return (
<main>
<Child onLoadButtonClick={load} />
</main>
);
}

The initial state of the machine is Waiting for response. In this state, we listen to Received response event. When we receive this event, we transition to Sending response to server, in which we do something with the response. Because in Sending response to server state we also listen to Received response, if the response changes while the last response has not finished being sent, the last operation will be cancelled, and the new request will be sent. The previous code without XState behaved the same way, but now the behavior is explicit and way more predictable.

Note that we are still using useEffect hook. XState can be used in conjunction with libraries from classic React’s ecosystem. Thanks to a useEffect we can synchronize our machine with other hooks.

permalinkConclusion

We went from a solution where a child component was handling a part of the logic, where we had a hard time figuring out how to solve extraneous runs of an effect, to a solution where the parent component centralizes the logic and makes it totally explicit. The lesson to learn from this experience is that logic should always go from top to bottom. The logic should be centralized high in components tree, not be put in handlers of HTML elements, nor in child components. This makes the code more predictable, and then less error-prone and easier to maintain.

XState encourages this practice. It can be used in conjunction with libraries bringing hooks without any fear, notably thanks to useEffect hook.

Join my mailing list

Get new articles directly in your inbox.

I value your privacy and will never share your email address.