Storing and calling functions in React Context leads to weird behavior

Viewed 324

I am trying to store a simple callback in the React context that is set by one component and then called by another component, to facilitate communication between them. This is because the two components do not have any child-parent relationship and are in distinct subtrees of the app. And, I want to assign a custom event handler from Component1 to the click behavior of Component2.

Accordingly, I created the context with a function (the callback) and its setter

import { createContext, useContext, useState } from "react"
interface ContextShape {
    myCallback?: (searchInput: string) => void
    setMyCallback: (onSearchCallback: (searchInput: string) => void) => void
}

const MyContext = createContext<ContextShape>({
    myCallback: () => {},
    setMyCallback: () => {},
})

export const useMyContext = () => useContext(MyContext)

export const MyContextProvider: React.FC = (props) => {
    const [myCallback, setMyCallback] = useState<(searchInput: string) => void>(() => {})

    return (
        <MyContext.Provider
            value={{
                myCallback: myCallback,
                setMyCallback: (clientCallback) => {
                    setMyCallback(clientCallback)
                },
            }}
        >
            {props.children}
        </MyContext.Provider>
    )
}

The component where the click event originates, calls the callback like so:

const { myCallback } = useMyContext()

const handleKeyPress = (
    e: React.KeyboardEvent<HTMLInputElement>,
    sVal: string | undefined
) => {
    if (e.code === "Enter" && myCallback) {
        myCallback(sVal || "")
    }
}

And, I am trying to set the callback like so, from a page in my app:

useEffect(() => {
    // eslint-disable-next-line no-console
    setMyCallback(() => (text: string) => console.log("Hii" + text))
}, [])

Notice something weird? While setting the callback, I have to wrap it in another function (() => {... my callback...}). Only then, does this behave as expected. If I pass the callback directly, then it is immediately called instead of being stored and called later. (I checked extensively for whether it is being called mistakenly somewhere, but nowhere does the code do myCallback() unintentionally). This is something that is unexpected for me, as I expect that just (text: string) => {...} is enough for a callback and does not denote that I want to call the function immediately.

1 Answers

The reason you have to create a curried function as a callback is that the state updater takes a callback function too as its argument which it immediately invokes:

Ex:

setMyCallback((prev) => {
   console.log(prev, 'prev state');
});

Now if you simply pass a callback function to the setMyCallback function, it gets treated as

setMyCallback((text: string) => console.log("Hii" + text));

and hence console.log() is immediately invoked.

You can update your code to as follows to avoid such issues:

export const MyContextProvider: React.FC = (props) => {
    const [myCallback, setMyCallback] = useState<(searchInput: string) => void>(() => {})

    return (
        <MyContext.Provider
            value={{
                myCallback: myCallback,
                setMyCallback: (clientCallback) => {
                    // Already use a callback function
                    setMyCallback(() => clientCallback)
                },
            }}
        >
            {props.children}
        </MyContext.Provider>
    )
}

and then use it as

useEffect(() => {
    // eslint-disable-next-line no-console
    setMyCallback((text: string) => console.log("Hii" + text))
}, []);
Related