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
importReact , {useEffect } from "react";import {useResponse } from "./useResponse";import {sendResponseToServer } from "./store";interfaceChildProps {onResponseChange : (response :Response ) => void;}functionChild ({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 functionApp () {// This function will be redeclared during each render.async functionhandleResponseChange (response :Response ) {console .log ("received response",response );awaitsendResponseToServer (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
importReact , {useEffect ,useCallback } from "react";import {useResponse } from "./useResponse";import {sendResponseToServer } from "./store";interfaceChildProps {onResponseChange : (response :Response ) => void;}functionChild ({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 functionApp () {// This function will be declared once.consthandleResponseChange =useCallback (async (response :Response ) => {console .log ("received response",response );awaitsendResponseToServer (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
importReact , {useEffect ,useCallback } from "react";import {createMachine } from 'xstate';import {useInterpret } from '@xstate/react';import {useResponse } from "./useResponse";import {sendResponseToServer } from "./store";interfaceChildProps {onResponseChange : (response :Response ) => void;}functionChild ({onResponseChange }:ChildProps ) {const [response ,load ] =useResponse ();constservice =useInterpret (() => {returncreateMachine ({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 newError ('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 functionApp () {// This is no longer an issue if the function is re-declared// for each render.async functionhandleResponseChange (response :Response ) {console .log ("received response",response );awaitsendResponseToServer (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
importReact , {useEffect } from "react";import {useResponse } from "./useResponse";import {sendResponseToServer } from "./store";interfaceChildProps {onLoadButtonClick : () => void;}// The Child component does not contain logic anymore.functionChild ({onLoadButtonClick }:ChildProps ) {return (<button onClick ={onLoadButtonClick }>Load</button >);}export default functionApp () {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 functionhandleResponseChange (response :Response ) {console .log ("received response",response );awaitsendResponseToServer (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
importReact , {useEffect } from "react";import {createMachine ,assign } from "xstate";import {useMachine } from "@xstate/react";import {useResponse } from "./useResponse";import {sendResponseToServer } from "./store";interfaceChildProps {onLoadButtonClick : () => void;}// The Child component does not contain logic.functionChild ({onLoadButtonClick }:ChildProps ) {return (<button onClick ={onLoadButtonClick }>Load</button >);}export default functionApp () {const [response ,load ] =useResponse ();const [state ,send ] =useMachine (() => {returncreateMachine ({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 newError ('Response is null');}returnevent .response ;},}),},services : {'Send response to server': async ({response }) => {if (response ===undefined ) {throw newError ('Response must have been stored into context');}console .log ("received response",response );awaitsendResponseToServer (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.