Notes of Maks Nemisj

Experiments with JavaScript

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.

, , ,

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.