I have two hooks, useA and useB, which perform expensive operations (let's assume network API calls) and these values need to be saved into a global application state (AppContext) using React's Context API. Furthermore, the behaviour of the second hook, useB, depends on the result of the first hook, useA.
These hooks are invoked once the application is started and the context provider is created. I call the hooks in the context provider component, and I consume the same context inside the second hook. This creates a problem which results into stale information; the useContext(AppContext) inside useB does not provide correct results for useA. Because the result of useA is needed outside of the context of the useB hook, I cannot move its invocation under the useB hook. Otherwise, this would result into two calls to useA, once inside the context provider and once inside useB.
How could this issue be solved?
const useA = () => {
const api = useApi()
const [state, setState] = useState(null)
useEffect(() => {
api.someFn().then((result) => {
setState(result)
})
}, [api])
return state
}
const useB = () => {
const api = useApi()
const { a } = useContext(AppContext)
const [state, setState] = useState(null)
useEffect(() => {
api.someOtherFn(a.someVariable).then((result) => {
setState(result)
})
}, [api, a])
return state
}
const AppContext = createContext({ a: null, b: null })
const AppContextProvider = (children) => {
const a = useA()
const b = useB()
const context = { a, b }
return <AppContext.Provider value={context}>{children}</AppContext.Provider>
}
const App = () => {
return (
<AppContextProvider>
<Router>
...
</Router>
</AppContextProvider>
)
}
Maybe the following will be useful:
const useA = () => {
const api = useApi()
const [state, setState] = useState(null)
useEffect(() => {
api.someFn().then((result) => {
setState(result)
})
}, [api])
return state
}
const useB = (a) => {
const api = useApi()
const [state, setState] = useState(null)
useEffect(() => {
if (a && a.someVariable) {
api.someOtherFn(a.someVariable).then((result) => {
setState(result)
})
}
}, [api, a])
return state
}
const AppContext = createContext({ a: null, b: null })
const AppContextProvider = (children) => {
const a = useA()
const b = useB(a)
// It is not recommended to pack several values into some object and share it via context
// Cause consumers will be retriggered on each call to AppContextProvider
const context = { a, b }
return <AppContext.Provider value={context}>{children}</AppContext.Provider>
}
const App = () => {
return (
<AppContextProvider>
<Router>
...
</Router>
</AppContextProvider>
)
}
Please read about multiple contexts https://reactjs.org/docs/context.html#consuming-multiple-contexts
Related
Description
I'm creating a state management tool for a small project, using mainly useSyncExternalStore from React, inspired by this video from Jack Herrington https://www.youtube.com/watch?v=ZKlXqrcBx88&ab_channel=JackHerrington.
But, I'm running into a pattern that doesn't look right, which is having to use 2 providers, one to create the state, and the other to initialise it.
The gist of the problem:
I have a property sessionId coming from an HTTP request. Saving it in my store wasn't an issue.
However, once I have a sessionId then all of my POST requests done with notifyBackend should have this sessionId in the request body. And I was able to achieve this requirement using the pattern above, but I don't like it.
Any idea how to make it better ?
Code
CreateStore.jsx (Not important, just providing the code in case)
export default function createStore(initialState) {
function useStoreData(): {
const store = useRef(initialState);
const subscribers = useRef(new Set());
return {
get: useCallback(() => store.current, []),
set: useCallback((value) => {
store.current = { ...store.current, ...value };
subscribers.current.forEach((callback) => callback());
}, []),
subscribe: useCallback((callback) => {
subscribers.current.add(callback);
return () => subscribers.current.delete(callback);
}, []),
};
}
const StoreContext = createContext(null);
function StoreProvider({ children }) {
return (
<StoreContext.Provider value={useStoreData()}>
{children}
</StoreContext.Provider>
);
}
function useStore(selector) {
const store = useContext(StoreContext);
const state = useSyncExternalStore(
store.subscribe,
() => selector(store.get()),
() => selector(initialState),
);
// [value, appendToStore]
return [state, store.set];
}
return {
StoreProvider,
useStore,
};
}
Creating the state
export const { StoreProvider, useStore } = createStore({
sessionId: "INITIAL",
notifyBackend: () => { },
});
index.jsx
<Router>
<StoreProvider>
<InitialisationProvider>
<App />
</InitialisationProvider>
</StoreProvider>
</Router
InitialisationContext.jsx
const InitialisationContext = createContext({});
export const InitializationProvider = ({ children }) {
const [sessionId, appendToStore] = useStore(store => store.session);
const notifyBackend = async({ data }) => {
const _data = {
...data,
sessionId,
};
try {
const result = await fetchPOST(data);
if (result.sessionId) {
appendToStore({ sessionId: result.sessionId });
} else if (result.otherProp) {
appendToStore({ otherProp: result.otherProp });
}
} catch (e) { }
};
useEffect(() => {
appendToStore({ notifyBackend });
}, [sessionId]);
return (
<InitialisationContext.Provider value={{}}>
{children}
</InitialisationContext.Provider>
);
}
I just tried out Zustand, and it's very similar to what I'm trying to achieve.
Feels like I'm trying to reinvent the wheel.
With Zustand:
main-store.js
import create from 'zustand';
export const useMainStore = create((set, get) => ({
sessionId: 'INITIAL',
otherProp: '',
notifyBackend: async ({ data }) => {
const _data = {
...data,
sessionId: get().sessionId,
};
try {
const result = await fetchPOST(data);
if (result.sessionId) {
set({ sessionId: result.sessionId });
} else if (result.otherProp) {
set({ otherProp: result.otherProp });
}
} catch (e) { }
},
}));
SomeComponent.jsx
export const SomeComponent() {
const sessionId = useMainStore(state => state.sessionId);
const notifyBackend = useMainStore(state => state.notifyBackend);
useEffect(() => {
if (sessionId === 'INITIAL') {
notifyBackend();
}
}, [sessionId]);
return <h1>Foo</h1>
};
This answer focuses on OPs approach to createStore(). After reading the question a few more times, I think there are bigger issues. I'll try to get to these and then extend the answer.
Your approach is too complicated.
First, the store is no hook! It lives completely outside of react. useSyncExternalStore and the two methods subscribe and getSnapshot are what integrates the store into react.
And as the store lives outside of react, you don't need a Context at all.
Just do const whatever = useSyncExternalStore(myStore.subscribe, myStore.getSnapshot);
Here my version of minimal createStore() basically a global/shared useState()
export function createStore(initialValue) {
// subscription
const listeners = new Set();
const subscribe = (callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
}
const dispatch = () => {
for (const callback of listeners) callback();
}
// value management
let value = typeof initialValue === "function" ?
initialValue() :
initialValue;
// this is what useStore() will return.
const getSnapshot = () => [value, setState];
// the same logic as in `setState(newValue)` or `setState(prev => newValue)`
const setState = (arg) => {
let prev = value;
value = typeof arg === "function" ? arg(prev) : arg;
if (value !== prev) dispatch(); // only notify listener on actual change.
}
// returning just a custom hook
return () => useSyncExternalStore(subscribe, getSnapshot);
}
And the usage
export const useMyCustomStore = createStore({});
// ...
const [value, setValue] = useMyCustomStore();
For fun and a bit of a challenge, I'm trying to extend on a pattern I saw for a simple state manager that uses refs to store state and a pub/sub method to update subscribers.
The original implementation only allowed the hook to be passed a selector function that would return a slice of the state - I'm stuck on a way to type the function that it can properly infer whether it returns an entire state object if no selector is passed, or just the state when it is:
Here is a working link (codesandbox)
and the relevant code:
function createStore<Store>(initialState: Store) {
const useStoreData = () => {
const store = useRef<Store>(initialState);
const subscribers = useRef(new Set<() => void>());
const get = () => store.current;
const set = (value: Partial<Store>) => {
store.current = { ...store.current, ...value };
subscribers.current.forEach((cb) => cb));
}
const subscribe = (callback: () => void) => {
subscribers.current.add(callback);
return () => subscribers.current.delete(callback);
}
return { get, set, subscribe };
}
const StoreContext = createContext<ReturnType<typeof useStoreData> | null>(null);
function Provider({ children }: { children: React.ReactNode }) {
return (
<StoreContext.Provider value={useStoreData()}>
{children}
</StoreContext.Provider>
);
}
function useStore<SelectorOutput>(
selector: (store: Store) => SelectorOutput
): [SelectorOutput, (value: Store) => void] {
const store = useContext(StoreContext);
if (!store) {
throw new Error("Store not found");
}
const [state, setState] = useState(selector(store.get()));
useEffect(() => {
return store.subscribe(() => setState(selector(store.get())));
}, [store]);
return [state, store.set];
}
return {
Provider,
useStore
};
}
const { Provider, useStore } = createStore({ first: '', last: '' })
function DisplayValue() {
const [first] = useStore((state) => state.first);
}
I've omitted parts like wrapping the App in the Provider etc, but all the above works fine - however if I want to do something such as:
function DisplayAll() {
const [state] = useStore() // { first: string, last: string }
}
I can't work out how to properly type this out. Can anyone point out some tips?
I am currently asking myself the following question:
Is it recommended that I define my state and logic directly in the ContextProvider or is it okay if I define the state and logic in a separate function to separate the code a bit?
Example:
const MyContext = React.createContext({});
const createStore = () => {
const [myState, setMyState] = useState();
return {
myState,
setMyState
}
}
const MyContextProvider = ({ children }) => {
const store = createStore();
return (
<MyContext.Provider value={store}>{children}</MyContext.Provider>
)
}
I am a little bit affraid of that createStore function. Does the createStore always gets recreated if the Provider rerenders ?
Edit:
Thanks for the answer!
What if I want to use a parameter in the useCreateStore hook ?
Will the parameter gets updated?
Example:
const MyContext = React.createContext({});
const useCustomStore= (myAwesomeValue) => {
const [myState, setMyState] = useState();
const doSomething = useCallback(() => {
//
}, [myAwesomeValue])
return {
myState,
setMyState
}
}
const MyContextProvider = ({ children, title }) => {
const { myState } = useCustomStore(title); //You need to desctructure the returned object here, note myState
return (
<MyContext.Provider value={myState}>{children}</MyContext.Provider>
)
}
What you are trying to create for your "store" is called a custom hook
You will need to make some changes though. It is customary to use 'use' as the start of a custom hook. so, here I have renamed createStore to useCustomStore. Since it is a custom hook with useState, it follows the same rules as if you actually had it within your context provider
Also, your custom hook returns an object which contains the state and a mutation method. you will need to access the state either directly store.myState or you can destructure it { myState} as I have in the example.
const MyContext = React.createContext({});
const useCustomStore= () => {
const [myState, setMyState] = useState();
return {
myState,
setMyState
}
}
const MyContextProvider = ({ children }) => {
const { myState } = useCustomStore(); //You need to desctructure the returned object here, note myState
return (
<MyContext.Provider value={myState}>{children}</MyContext.Provider>
)
}
Is the same as
const MyContext = React.createContext({});
const MyContextProvider = ({ children }) => {
const [myState, setMyState] = useState();
return (
<MyContext.Provider value={myState}>{children}</MyContext.Provider>
)
}
So rerenders will preserve state, since it uses the useState hook.
I'm having issue where hook is being used in multiple files and it is being called twice for useEffect before the 1st one's async method finish (which should block the 2nd hook call, but it's not). See below 2 scenarios:
Stack Navigator
const { context, state } = useLobby(); // Hook is called here 1st, which will do the initial render and checks
return (
<LobbyContext.Provider value={context}>
<LobbyStack.Navigator>
{state.roomId
? <LobbyStack.Screen name="Lobby" component={LobbyScreen} />
: <LobbyStack.Screen name="Queue" component={QueueScreen} />
}
</LobbyStack.Navigator>
</LobbyContext.Provider>
)
Lobby Hooks
export const useLobby = () => {
const [state, dispatch] = React.useReducer(...)
//
// Scenario 1
// This get called twice (adds user to room twice)
//
React.useEffect(() => {
if (!state.isActive) assignRoom();
}, [state.isActive])
const assignRoom = async () => {
// dispatch room id
}
const context = React.useMemo(() => ({
join: () => { assignRoom(); }
})
}
Queue Screen
const { context, state } = useLobby(); // Hook is called here 2nd right after checking state from stack navigator
//
// Scenario 2
// Only does it once, however after state is changed to active
// the stack navigator didn't get re-render like it did in Scenario 1
//
React.useEffect(() => {
roomLobby.join();
}, []);
return (
...
{state.isActive
? "Show the room Id"
: "Check again"
...
)
In scenario 1, I guess while 1st hook is called and useEffect is doing async to add user to the room and set active to true. Meanwhile the conditional render part is moving straight to Queue screen which calls the hook again and doing the useEffect (since 1st haven't finished and isActive is still false).
How can I properly setup useReducer and useMemo so that it renders the screen base on the state.
Edited codes based on the answer
/* LobbyProvider */
const LobbyContext = React.createContext();
const lobbyReducer = (state, action) => {
switch (action.type) {
case 'SET_LOBBY':
return {
...state,
isActive: action.active,
lobby: action.lobby
};
case 'SET_ROOM':
return {
...state,
isQueued: action.queue,
roomId: action.roomId,
};
default:
return state;
}
}
const LobbyProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(lobbyReducer, initialState);
React.useEffect(() => {
console.log("Provider:", state)
if (!state.isActive) joinRoom();
}, [])
// Using Firebase functions
const joinRoom = async () => {
try {
const response = await functions().httpsCallable('getActiveLobby')();
if (response) {
dispatch({ type: 'SET_LOBBY', active: true, lobby: response.data })
const room = await functions().httpsCallable('assignRoom')({ id: response.data.id });
dispatch({ type: 'SET_ROOM', queue: false, roomId: room.data.id })
}
} catch (e) {
console.error(e);
}
}
return (
<LobbyContext.Provider value={{state, dispatch}}>
{ children }
</LobbyContext.Provider>
)
}
/* StackNavigator */
const {state} = React.useContext(LobbyContext);
return (
<LobbyProvider>
// same as above <LobbyStack.Navigator>
// state doesn't seem to be updated here or to re-render
</LobbyProvider>
);
/* Queue Screen */
const {state} = React.useContext(LobbyContext);
// accessing state.isActive to do some conditional rendering
// which roomId does get rendered after dispatch
You must note that a custom hook will create a new instance of state everytime its called.
For example, you call the hook in StackNavigator component and then again in QueueScreen, so 2 different useReducers will be invoked instead of them sharing the states.
You should instead use useReducer in StackNavigator's parent and then utilize that as context within useLobby hook
const LobbyStateContext = React.createContext();
const Component = ({children}) => {
const [state, dispatch] = React.useReducer(...)
return (
<LobbyStateContext.Provider value={[state, dispatch]]>
{children}
</LobbyStateContext>
)
}
and use it like
<Component>
<StackNavigator />
</Component>
useLobby will then look like
export const useLobby = () => {
const [state, dispatch] = React.useContext(LobbyStateContext)
const assignRoom = async () => {
// dispatch room id
}
const context = React.useMemo(() => ({
join: () => { assignRoom(); }
})
return { context, assignRoom, state};
}
StackNavigator will utilize useLobby and have the useEFfect logic
const { context, state, assignRoom } = useLobby();
React.useEffect(() => {
if (!state.isActive) assignRoom();
}, [state.isActive])
return (
<LobbyContext.Provider value={context}>
<LobbyStack.Navigator>
{state.roomId
? <LobbyStack.Screen name="Lobby" component={LobbyScreen} />
: <LobbyStack.Screen name="Queue" component={QueueScreen} />
}
</LobbyStack.Navigator>
</LobbyContext.Provider>
)
Context.js
const GlobalContext = React.createContext();
const initState = {count:0};
const GlobalContextProvider = props => {
const [state, setState] = useState(initState);
return (
<GlobalContext.Provider value={{state:state, setState:setState}}>
{props.children}
</GlobalContext.Provider>
)
};
const GlobalContextValue = useContext(GlobalContext)
export {GlobalContextValue, GlobalContextProvider}
When I exported the GlobalContextValue, Chrome or React throws an error saying this is an invalid hook call, but I want to be able use setState in a module that's showing below.
fetchAPI.js
import { GlobalContextValue } from './GlobalContext';
const {state, setState} = GlobalContextValue;
function load() {
fetch('localhost:8000/load')
.then(res => res.json())
.then(json => setState(json));
};
You can't use hooks outside of React functional components.
You can probably do this another way though.
Disclaimer: I didn't test this code, but it should do what you want, although I don't recommend doing this at all.
const GlobalContext = React.createContext();
const globalState = { count: 0 }
let subscribers = []
export function setGlobalState(value) {
Object.assign(globalState, value)
subscribers.forEach(f => f(globalState))
}
export function subscribe(handler) {
subscribers.push(handler)
return () => {
subscribers = subscribers.filter(s => s !== handler)
}
}
const GlobalContextProvider = props => {
const [state, setState] = useState(globalState)
useEffect(() => subscribe(setState), [])
return (
<GlobalContext.Provider value={{ state: state, setState: setGlobalState }}>
{props.children}
</GlobalContext.Provider>
);
};