Since react introduced hooks, useEffect
has become a challenge for many. This hook offers an elegant way to declare side effects in our code, but it comes with a price: we need to define its inner dependencies and this can be an issue sometimes.
The problem
Say we have a component that calls a handler when its internal state changes. This is usually a smell that shows that we placed the state in the wrong component (it should be in the parent) but we can still face this situation when we deal with external libraries or an old codebase.
function Parent(){
return <Child onChange={doSomething} />
}
function Child({ onChange }){
const [state, setState] = useState()
useEffect(()=> {
onChange(state)
}, [state, onChange])
return ...
}
I recently faced this problem when using react-table
. I needed a component that could encapsulate React Table’s API in order to replace other tables with my new component, but React Table holds all the table’s state in a custom hook. If we want to provide a handler for the selected rows in the table, the best way to do this is with a useEffect
.
Let’s check this example for our case study, and create a handler function to track when the selected rows change.
// our new Table component with react-table
function Table({ onChangeSelection }) {
const [value] = useTable(config)
const { selected } = value.state
useEffect(() => {
onChangeSelection(selected)
}, [selected, onChangeSelection])
// ...
}
// a component that needs the selection
function Page() {
const [selection, setSelection] = useState({})
// this will cause an infinite loop:
// a re-render in Page creates a new handleSelection
// a new handleSelection triggers Table's useEffect
// Page will re-render if the new value is a new object instance
const handleSelection = (value) => setSelection(value)
return (
<div>
<OtherComponent selection={selection} />
<Table onChangeSelection={handleSelection} />
</div>
)
}
Table
component provides a handler to keep track of changes in the selected state, while Page
uses that handler to keep the state updated. A useEffect
will tell when the Table
’s state changes and call the handler. But to do this properly, the dependencies array has to include the state that we’re subscribing to and the handler.
Adding the handler in this array, forces the parent component to memoize this function. Otherwise, every new render in the parent component will create a new handler. Since it’s a new function, the effect will be triggered again with the previous state.
This is a problem because the handler is going to be called not only whenever the row selection changes, but also when the handler changes. This means that the handler is not only reporting when the Selection changed but also when the handler change. Notice that, if the handler function is updating a state in the parent component, it could create an infinite loop (infinite re-renders).
If you have worked with react-table you probably have dealt with many infinite re-renders situations. Now let’s see how we can fix this.
A solution
The simplest solution to this issue is to wrap the handler function in a useCallback
. This way we keep the same handler between renders. Since the handler remains the same, the effect won’t be triggered and the handler will only be called when the selection state changes.
function Table({ onChangeSelection }) {
const [value] = useTable(config)
const { selected } = value.state
useEffect(()=> {
onChangeSelection(selected)
}, [selected, onChangeSelection])
// ...
}
function Page() {
const [selection, setSelection] = useState({})
// useCallback keeps the same instance of handleSelection between renders
// useEffect will only be triggered when the selection changes
const handleSelection = useCallback((value) => setSelection(value), [])
return (
<div>
<OtherComponent selection={selection} />
<Table onChangeSelection={handleSelection} />
</div>
)
}
Is this good enough?
That worked. We solved the issue without much effort. However, unless you dug into the Table
's implementation or documentation, it’s quite likely that you would create an infinite loop before finding out that you need a useCallback
.
There’s no way to tell other developers that this handler needs to be memoized. At the same time, memoizing every single handler in our codebase just in case feels redundant and doesn’t solve the issue at its origin, in the Table component. In my opinion, the need for callback memoization is one of the downsides of today’s react idiomatic code.
In an ideal scenario, we would be able to use an inline function as a handler, without having to guess whether we should memoize it. The handler should also be included in the dependency array of the effect. But, is that even possible?
Luckily for us, there’s a way to avoid forcing other developers to memoize their handlers when they use our components.
Using an Event Emitter
In programming, event emitters (or event bus) are used to decouple interfaces. An Event bus basically keeps track of the listeners for a certain event. When that event is emitted in the bus, the bus will notify all the listeners with the event’s payload.
// basic usage of EventEmitter
import EventEmitter from 'events'
const emitter = new EventEmitter()
const hello = (value) => console.log('hello', value)
emitter.on('change', hello)
emitter.emit('change', 'there') // log: hello there
You can already see how appropriate this is for our case. Now let’s dig into the React specifics. The trick here is to use the bus as an intermediary between handlers and state changes. The handlers will be subscribed for events to the bus. Then, when the state changes, an effect will dispatch events into the bus. Since the emitter function remains the same, the effect that subscribes to the state changes won’t be affected when a new handler is provided.
// keep same emitter instance between renders
const emitter = useRef(new EventEmitter())
// create a dispatch function that doesn't change between renders
const dispatch = useCallback((...payload) => {
emitter.current.emit('aRandomEventName', ...payload)
}, [])
// subscribe our emitter to state changes
// notice dispatch remain the same between renders
// only state will trigger the effect
useEffect(() => {
dispatch(state)
}, [state, dispatch])
// subscribe the handler to the events
// this effect decouples our handler from the state change
useEffect(()=> {
emitter.current.on('aRandomEventName', handler)
// don't forget to unsubscribe the handler
return ()=> {
emitter.current.off('aRandomEventName', handler)
}
}, [handler, dispatch])
This logic can now be defined in a nice hook for easier use.
import EventEmitter from "events";
import { useCallback, useEffect, useRef } from "react";
export default function useListener(listener = () => {}) {
const emitter = useRef(new EventEmitter());
useEffect(() => {
const currentEmitter = emitter.current;
currentEmitter.on("event", listener);
return () => {
currentEmitter.off("event", listener);
};
}, [listener]);
const dispatch = useCallback((...payload) => {
emitter.current.emit("event", ...payload);
}, []);
return [dispatch, emitter];
}
Finally, let’s use the new hook in our table.
function Table({ onChangeSelection }) {
const [value] = useTable(config)
const [dispatch] = useListener(onChangeSelection)
const { selected } = value.state
useEffect(()=> {
dispatch(selected)
// dispatch won't change when onChangeSelection changes
}, [selected, dispatch])
// ...
}
function Page() {
const [selection, setSelection] = useState({})
return (
<div>
<OtherComponent selection={selection} />
{/* we can use inline functions for handlers with ease now */}
<Table onChangeSelection={(value) => setSelection(value)} />
</div>
)
}
Now we can safely use inline functions for our handlers without worrying about infinite re-renders.
I hope you find this as useful as I did. You can find the full code snippet in this gist.
Top comments (0)