2

I'm trying to reuse a bunch of custom hooks without re-invoking them and without maintaining an order through which I'll have to pass cascading parameters from one hook to the other.

A working example: https://codesandbox.io/s/laughing-firefly-mlhdw?file=/src/App.js:0-1158

Given the following code:

import React, { useContext, useEffect, useState } from "react";

const globalContext = React.createContext({
  user: null,
  pet: null
});

const usePet = () => {
  const [pet, setPet] = useState(null);
 
  useEffect(() => {
    setTimeout(() => {
      setPet("Dog");
    }, 3000);
  }, []);

  return pet;
};

const useUser = () => {
  const [user, setUser] = useState(null);
  // I want to proxy pet via the context so that I won't have to re-invoke its side-effects again
  const { pet } = useContext(globalContext);

  useEffect(() => {
    setTimeout(() => {
      setUser("john");
    }, 500);
  }, []);

  // This is only called once with the default value (null)
  useEffect(() => {
    console.log("Called from user!", { pet });
  }, [pet]);

  return user;
};

export const StateProvider = ({ children }) => {
  const user = useUser();
  const pet = usePet();

  useEffect(() => {
    console.log("StateProvider", { user, pet });
  }, [user, pet]);

  return (
    <globalContext.Provider value={{ user, pet }}>
      {children}
    </globalContext.Provider>
  );
};

export default function App() {
  const { user, pet } = useContext(globalContext);
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>
        {pet} {user}
      </h2>
    </div>
  );
}

// imagine an index.js that's wrapping the App component like this:
const rootElement = document.getElementById("root");
ReactDOM.render(
  <StrictMode>
    <StateProvider>
      <App />
    </StateProvider>
  </StrictMode>,
  rootElement
);

What I'm expecting to see in the console output is the following


Called from user! {pet: null}
StateProvider {user: null, pet: null}
StateProvider {user: "john", pet: null}
StateProvider {user: "john", pet: "Dog"}
Called from user! {pet: "Dog"}

But I'm not getting any updates inside useUser other than the initial state:

Called from user! {pet: null}
StateProvider {user: null, pet: null}
StateProvider {user: "john", pet: null}
StateProvider {user: "john", pet: "Dog"}
<!-- no update here, pet is still null for the useUser hook -->

My questions are:

  1. Is it possible to achieve that? if it is, what am I missing here?
  2. If it's not possible, is there a more elegant way of passing data between custom hooks without re-invoking them (creating a new state context for each invocation) and without passing parameters from one another, which will force me to also maintain order between everything?

Just to clarify - the UI is working as expected and all the values are rendered correctly inside the component.

Also, when passing the parameters directly to the hook, things are also in order

const pet = usePet(); 
const user = useUser(pet); //this will work as it doesn't go through the context

silicakes
  • 6,364
  • 3
  • 28
  • 39
  • 5
    You are running `useUser` outside the context tree, it will never work unless you you run it below the context. – Sagiv b.g Feb 01 '22 at 07:56

2 Answers2

4

To understand why your expectations are being violated, you must first understand how the context API works, including the useContext hook.

I'll include an especially relevant snippet here:

useContext

const value = useContext(MyContext);

Accepts a context object (the value returned from React.createContext) and returns the current context value for that context. The current context value is determined by the value prop of the nearest <MyContext.Provider> above the calling component in the tree.

When you use the context hook, your component which invokes the hook only receives updates when the context is updated. If you invoke the hook outside the root of a context provider tree, there will never be an update. This is what's happening in your example code.

An easy solution to this is to simply move the invocation of the hooks which depend on the context into a separate component below the context provider root. However, because of the way that your custom hooks are co-dependent (and neither actually update the context itself), this still leaves you in a stalemate of circular dependency.

To address this, you must give yourself the ability to update the context: by including a state setter that you can invoke with values to update the state.

In the following refactor of your code, I've made both of these changes:

This is a very common pattern, and the most common iteration of it is using the useReducer hook in combination with the context API. You can find lots of examples by querying for react state context.

<div id="root"></div><script src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script><script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script><script src="https://unpkg.com/@babel/standalone@7.17.2/babel.min.js"></script>
<script type="text/babel" data-type="module" data-presets="env,react">

const {
  createContext,
  useContext,
  useEffect,
  useState,
} = React;

const initialContextState = {pet: null, user: null};

// You don't need to supply a default value here because you're no longer
// using it outside the provider root:
// const defaultContextValue = [initialContextState, () => {}];
// const appContext = createContext(defaultContextValue);

const appContext = createContext();

const usePet = () => {
  const [{pet}, setState] = useContext(appContext);
  useEffect(() => setTimeout(() => setState(state => ({...state, pet: 'Dog'})), 3000), [setState]);
  return pet;
};

const useUser = () => {
  const [{pet, user}, setState] = useContext(appContext);
  useEffect(() => setTimeout(() => setState(state => ({...state, user: 'John'})), 500), [setState]);
  useEffect(() => console.log('Called from user!', {pet}), [pet]);
  return user;
};

// This must be rendered within the context provider root
const ContextDependentHooksInvoker = () => {
  const pet = usePet();
  const user = useUser();

  useEffect(
    () => console.log('ContextDependentHooksInvoker', {pet, user}),
    [user, pet],
  );

  return null;
};

const StateProvider = ({children}) => {
  const stateWithSetter = useState(initialContextState);
  return (
    <appContext.Provider value={stateWithSetter}>
      <ContextDependentHooksInvoker />
      {children}
    </appContext.Provider>
  );
};

function App () {
  const [{pet, user}] = useContext(appContext);
  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>{pet} {user}</h2>
    </div>
  );
}

function Example () {
  return (
    <React.StrictMode>
      <StateProvider>
        <App />
      </StateProvider>
    </React.StrictMode>
  );
}

ReactDOM.render(<Example />, document.getElementById('root'));

</script>
jsejcksn
  • 27,667
  • 4
  • 38
  • 62
0

To answer your question:

1. Yes, this is possible to achieve.

The thing that was missing here is that your useUser() is called inside StateProvider component, i.e. on the same level with the context provider, whereas for useContext() to work, it has to be called one level down the context provider (StateProvider as a wrapper). In this case, it would be your App component.

A working code would be as follow:

export default function App() {
  const { user, pet } = useContext(globalContext);

  // this one will print out in console
  // Called from user!  {pet: "Dog"}
  // as expected
  const userWithInContext = useUser();

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>
        {pet} {user}
      </h2>
    </div>
  );
}

I tested it and it worked. I did not make any other changes, merely setting a new userWithInContext variable up there, using the same useUser() logic you provided. In console it will print these outputs:

// this is from userWithInContext inside App component
Called from user! {pet: "Dog"}
// this is from user variable inside StateProvider component
Called from user! {pet: null}

2. Since it is possible to achieve what you want, this is just a side note about elegance and readability as of why you may not want to call useContext() inside a custom hook.

kvooak
  • 275
  • 1
  • 3
  • 9
  • Thanks for your thorough comment @kvooak , however your solution requires me to re-invoke `useUser()` again - the exact thing I'm trying to avoid doing. I also want to clarify that the UI indeed renders as expected, regardless of the value inside `useUser()` I wonder why passing a parameter to the hook will actually pull the update through: `const pet = usePet(); const user = useUser(pet); //this will work as it doesn't go through the context` – silicakes Jan 31 '22 at 18:23