20 03 2020
useEffectlessState for data fetching with React hooks
Have you ever been into a situation where you wanted to do something with your state inside useEffect
hook but didn’t want to put it into a dependency array?
react-hooks/exhaustive-deps
is an excellent guard when working with primitive values, but as soon as you have an object in the state, it might stay on your way.
Let me show you some code, which requires state handling in the way I’ve described above.
type FetchInfo =
| { state: 'LOADING' }
| { state: 'LOADED' }
| { state: 'ERROR'; error: Error; statusCode: number | undefined }
| { state: 'INITIAL' }
interface StateType {
fetchInfo: FetchInfo
input: string | undefined
value: Value
}
export const VerySpecialComponent: React.FC<{ input: string }> = ({ input }) => {
const [state, setState] = React.useState<StateType>({
fetchInfo: { state: 'INITIAL' },
input: undefined,
value: undefined,
})
React.useEffect(() => {
if (state.fetchInfo.state === 'LOADING' || state.input === input) {
return
}
setState((state) => ({
...state,
fetchInfo: { state: 'LOADING' },
input,
}))
fetchStuff(input).then((result) => {
setState((state) => ({
...state,
value: result,
fetchInfo: { state: 'LOADED' },
}))
})
}, [input, state, setState])
return <div>{state.value}</div>
}
This code looks quite straightforward, still let me elaborate on it.
First, “VerySpecialComponent” component is introduced, which should react only to the property change. As soon as “input” changes from one value to another one, this component should fetch some information from the back-end. Though, I don’t want React to re-trigger useEffect
of this component, at the moment when state updates inside of the component useEffect
.
For this to happen, I’v implemented that dirty if
statement inside useEffect
, which checks for an already running effect.
In the past, before React Hooks existed, I could prevent this by using shouldComponentUpdate
lifecycle hook. Unfortunately this doesn’t exist in a land of the functional components.
Another moment I don’t like when using objects as dependencies for useEffect
, is that I do need to have an immutable state implementation ( using immutable.js or immer.js ), if I want to skip equal state changes. What I mean by that, is that calling setState
even with the same values, will produce new object and re-trigger useEffect
. Look at the code above, to better understand my concern:
// Calling setState, with the same fetchInfo, will still create a new reference of the state.
setState((state) => ({
...state,
fetchInfo: { state: 'LOADING' },
})
// this will re-trigger useEffect even though state is not changed
setState((state) => ({
...state,
fetchInfo: { state: 'LOADING' },
})
So all these items brought me to the plan of making an effectless state, which wouldn’t re-trigger running of the effect, whenever it changes inside useEffect
.
To achieve that, I will create an useEffectlessState
hook, which wouldn’t re-trigger useEffect
if state changes.
Let me show how it will be used:
type FetchInfo =
| { state: 'LOADING' }
| { state: 'LOADED' }
| { state: 'ERROR'; error: Error; statusCode: number | undefined }
| { state: 'INITIAL' }
interface StateType {
fetchInfo: FetchInfo
input: string | undefined
value: Value
}
export const VerySpecialComponent: React.FC<{ input: string }> = ({ input }) => {
const stateTuple = useEffectlessState<StateType>({
fetchInfo: { state: 'INITIAL' },
input: undefined,
value: undefined,
})
const [state] = stateTuple
React.useEffect(() => {
const [, setState] = stateTuple
setState((state) => ({
...state,
fetchInfo: { state: 'LOADING' },
input,
}))
fetchStuff(input).then((result) => {
setState((state) => ({
...state,
value: result,
fetchInfo: { state: 'LOADED' },
}))
})
}, [input, stateTuple])
return <div>{state.value}</div>
}
In the code above, by using useEffectlessState
i ensure that useEffect
will only run when input changes, but not the state itself. There are also other benefits to it. First of all, now I can implement AbortController functionality and abort the previous request inside the cleanup procedure of useEffect
as follow:
type FetchInfo =
| { state: 'LOADING' }
| { state: 'LOADED' }
| { state: 'ERROR'; error: Error; statusCode: number | undefined }
| { state: 'INITIAL' }
interface StateType {
fetchInfo: FetchInfo
input: string | undefined
value: Value
}
export const VerySpecialComponent: React.FC<{ input: string }> = ({ input }) => {
const stateTuple = useEffectlessState<StateType>({
fetchInfo: { state: 'INITIAL' },
input: undefined,
value: undefined,
})
const aboortControllerRef = React.useRef(new AbortController())
const [state] = stateTuple
React.useEffect(() => {
const aboortController = aboortControllerRef.current
const [, setState] = stateTuple
setState((state) => ({
...state,
fetchInfo: { state: 'LOADING' },
input,
}))
fetchStuff(input, aboortController.signal).then(
(result) => {
setState((state) => ({
...state,
value: result,
fetchInfo: { state: 'LOADED' },
}))
},
(error) => {
if (error.name !== 'AbortError') {
setState((state) => ({
...state,
fetchInfo: { state: 'ERROR', error, statusCode: undefined },
}))
}
},
)
return () => {
aboortController.abort()
}
}, [input, stateTuple])
return <div>{state.value}</div>
}
Another thing I like in this approach is that now it’s easier to extract functionality from useEffect
and put it into a separate function, which can be unit-tested (also this function will not have all the side effected ‘if’ statements):
export const run = function<T>(
stateTuple: StateTuple<T>,
signal: AbortSignal,
input: string,
) {
const [, setState] = stateTuple
setState((state) => ({
...state,
fetchInfo: { state: 'LOADING' },
input,
}))
fetchStuff(input, signal).then(
(result) => {
setState((state) => ({
...state,
value: result,
fetchInfo: { state: 'LOADED' },
}))
},
(error) => {
if (error.name !== 'AbortError') {
setState((state) => ({
...state,
fetchInfo: { state: 'ERROR', error, statusCode: undefined },
}))
}
},
)
}
export const VerySpecialComponent: React.FC<{ input: string }> = ({ input }) => {
const stateTuple = useEffectlessState<StateType>({
fetchInfo: { state: 'INITIAL' },
input: undefined,
value: undefined,
})
const aboortControllerRef = React.useRef(new AbortController())
const [state] = stateTuple
React.useEffect(() => {
const aboortController = aboortControllerRef.current
run(stateTuple, aboortController.signal, input)
return () => {
aboortController.abort()
}
}, [input, stateTuple])
return <div>{state.value}</div>
}
Clean and testable.
Time to have a look at how does useEffectlessState
hook is implemented:
import React from 'react'
export type StateTuple<T> = [T, React.Dispatch<React.SetStateAction<T>>]
export const useEffectlessState = <T>(initalState: T): StateTuple<T> => {
const [state, setState] = React.useState<T>(initalState)
const ref = React.useRef<StateTuple<T>>([state, setState])
ref.current[0] = state
ref.current[1] = setState
return ref.current
}
To have a state which wouldn’t re-trigger changes, I’ve created an immutable array and used its reference using ref hook. This allows me to change that array without re-triggering an update of the hook.
One caveat which exists in this approach is the reference to the state itself. When updating a state inside “run” function, I always have to make use callback style of setState
. This is needed so that tha latest version of the state object is used. Meaning it should be like setState(state => {})
and not “setState(newState)`.
In case I need the state inside run
function, it’s better to always use stateTuple[0]
to guarantee that state points to the latest object.
How to use async/await with forEach ( oneliner ) GIT: Learn by doing. Intro