How do I initialise state values and methods that uses useSyncExternalStore + Context in React? - reactjs

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();

Related

How to immediately update a state in React

I'm struggling with this problem and I've already tried many solutions but none of them fit me.
I have a context that I use to share information that I get from an API. I will summarize the files for you:
file: useGetInfo.tsx
type InfoContextData = { ... }
type Props = { ... }
type InfoResponseProps = { ... }
export const InfoContext = createContext<InfoContextData>({} as InfoContextData)
export const InformationProvider = ({ children }: Props) => {
const isBrowser = typeof window !== `undefined`
const [infoStorage, setInfoStorage] = useState(
isBrowser && localStorage.getItem('info')
? String(localStorage.getItem('info'))
: undefined
)
const [result, setResult] = useState<InfoResponseProps | null>(null)
const getInfo = useCallback(async (value: string) => {
const url = `<URL_FROM_API${value}>`
await axios.get(url)
.then((response) => {
setResult(response.data)
})
.catch((_) => {
setResult(null)
})
})
useEffect(() => {
if (!infoStorage) {
return
}
getInfo(infoStorage)
}, [infoStorage, getInfo])
return (
<InfoContext.Provider
value={{
result,
setResult,
infoStorage,
setInfoStorage,
getInfo,
}}
>
{children}
</InfoContext.Provider>
)
}
Then in the component I call the context:
file: SomeComponent.tsx
const Component = () => {
const { setInfoStorage, getInfo, result } = useContext(InfoContext)
const [input, setInput] = useState('')
const handleInfoSubmit = useCallback(() => {
getInfo(input)
if (!result || !result?.ok) {
localStorage.removeItem('info')
setInfoStorage(undefined)
}
setInfoStorage(input)
localStorage.setItem('info', 'input')
setInput('')
}, [input, result, getInfo, setInfoStorage, setInput])
return (
...
<Form onSubmit={handleInfoSubmit}>
<input>
...
</Form>
)
}
Basically, the user inserts a code in the form and when he submits the form, it runs the handleInfoSubmit function. Then, the code runs the function getInfo() and after requesting the API it returns the information to the state result.
The problem is in the SomeComponent.tsx file: when I run the function getInfo(input) I need the information in the state result but at the time axios finishes the request to the API and the code goes to the if (!result || !result?.ok) line, the result state is not still fulfilled.
I know that React/Gatsby can't update immediately the state like what I need, but is there a way to overcome this problem? Thanks in advance.
I think the value of the result would always be stale inside the handleInfoSubmit function per your code.
Rewrite the getInfo and handleInfoSubmit like this
// Return data from getInfo so that we can use the value directly in handleInfoSubmit
const getInfo = useCallback(async (value: string) => {
const url = `<URL_FROM_API${value}>`
try {
const { data } = await axios.get(url);
setResult(data)
return data;
} catch {
setResult(null)
}
return null;
})
const handleInfoSubmit = useCallback(async () => {
// await getInfo and get the axios response data.
const result = await getInfo(input)
if (!result || !result?.ok) {
localStorage.removeItem('info')
setInfoStorage(undefined)
}
setInfoStorage(input)
localStorage.setItem('info', 'input')
setInput('')
}, [input, getInfo, setInfoStorage, setInput])

How to type a hooks return value for cases of varying arity & return values (React/TS)

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?

React props are not passing for children... Why?

I am trying make an "easy" weather app exercise, just get data from api and render it. I am using "google api map" to get the location from a post code to a latitude and longitud parameters so I can use those numbers and pass it to "open weather map" api to get the weather for that location.
It is working but with bugs...
First I used redux for "location" and "weather". Redux was working but useSelector() wasnt displaying the data properly.
Then I decide to make it easy, on "search" component I am calling an api an getting the location I need, I am storing it with redux and it is working, on "weatherFullDispaly" component I am calling an api for the "weather" details and just pass it as props for the children to render the data but they are not getting it.
The thing is, while the app is running, when I put a post code I get an error because the children are not receiving the data but, if I comment out the children on the parent component and then comment in again, all the data print perfect.
Any help please???
const WeatherFullDisplay = () => {
const [weatherDetails, setWeatherDetails] = useState();
const currentLocation = useSelector(getLocationData);
useEffect(() => {
getWeatherDetails();
}, []);
const getWeatherDetails = async () => {
const API_KEY = process.env.REACT_APP_OPEN_WEATHER_MAP_API_KEY;
const { lat, lng } = await currentLocation.results[0].geometry.location;
const response = await axios.get(
`https://api.openweathermap.org/data/2.5/onecall?lat=${lat}&lon=${lng}&exclude=minutely&units=metric&appid=${API_KEY}`
);
setWeatherDetails(response.data);
};
return (
<div className="weather-full-display-details">
<WeatherNow weatherDetails={weatherDetails} />
<HourlyWeather weatherDetails={weatherDetails} />
<FiveDaysWeather weatherDetails={weatherDetails} />
</div>
);
};
const FiveDaysWeather = ({ weatherDetails }) => {
const displayDailyWeather = () => {
const daysToShow = [
weatherDetails.daily[1],
weatherDetails.daily[2],
weatherDetails.daily[3],
weatherDetails.daily[4],
weatherDetails.daily[5],
];
return daysToShow.map((day, i) => {
return (
<WeatherSingleCard
key={i}
typeOfCard="daily"
weekDay={moment(day.dt * 1000).format("dddd")}
icon={day.weather[0].icon}
weather={day.weather[0].main}
temp={day.temp.day}
/>
);
});
};
return (
<div className="day-single-cards">{displayDailyWeather()}</div>
);
};
import { createSlice } from "#reduxjs/toolkit";
const initialState = {
locationDetails: "",
};
const locationSlice = createSlice({
name: "location",
initialState,
reducers: {
setLocation: (state, action) => {
state.locationDetails = action.payload;
},
cleanLocation: (state) => {
state.locationDetails = ""
}
},
});
export const { setLocation, cleanLocation } = locationSlice.actions;
export const getLocationData = (state) => state.location.locationDetails;
export default locationSlice.reducer;
const SearchBar = () => {
const [postCode, setPostCode] = useState();
const [locationDetails, setLocationDetails] = useState();
const navigate = useNavigate();
const dispatch = useDispatch();
useEffect(() => {
getLocationDetails();
}, [postCode]);
const getLocationDetails = async () => {
const response = await axios.get(
"https://maps.googleapis.com/maps/api/geocode/json",
{
params: {
components: `country:ES|postal_code:${postCode}`,
region: "ES",
key: process.env.REACT_APP_GOOGLE_API_KEY,
},
}
);
setLocationDetails(response.data);
};
const handleSubmit = (e) => {
e.preventDefault();
dispatch(setLocation(locationDetails));
navigate("/detail-weather");
};
const handleChange = (e) => {
setPostCode(e.target.value);
};

React hooks & Context: Error when using context inside a child component with useEffect

I've created a react function component for the context as follows:
const ItemContext = createContext()
const ItemProvider = (props) => {
const [item, setItem] = useState(null)
const findById = (args = {}) => {
fetch('http://....', { method: 'POST' })
.then((newItem) => {
setItem(newItem)
})
}
let value = {
actions: {
findById
},
state: {
item
}
}
return <ItemContext.Provider value={value}>
{props.children}
</ItemContext.Provider>
}
In this way, I have my context that handles all the API calls and stores the state for that item. (Similar to redux and others)
Then in my child component further down the line that uses the above context...
const smallComponent = () =>{
const {id } = useParams()
const itemContext = useContext(ItemContext)
useEffect(()=>{
itemContext.actions.findById(id)
},[id])
return <div>info here</div>
}
So the component should do an API call on change of id. But I'm getting this error in the console:
React Hook useEffect has a missing dependency: 'itemContext.actions'. Either include it or remove the dependency array react-hooks/exhaustive-deps
If I add it in the dependency array though, I get a never ending loop of API calls on my server. So I'm not sure what to do. Or if I'm going at this the wrong way. Thanks.
=== UPDATE ====
Here is a jsfiddle to try it out: https://jsfiddle.net/zx5t76w2/
(FYI I realized the warning is not in the console as it's not linting)
You could just utilize useCallback for your fetch method, which returns a memoized function:
const findById = useCallback((args = {}) => {
fetch("http://....", { method: "POST" }).then(newItem => {
setItem(newItem);
});
}, []);
...and put it in the useEffect:
...
const { actions, state } = useContext(ItemContext)
useEffect(() => {
actions.findById(id)
}, [id, actions.findById])
...
Working example: https://jsfiddle.net/6r5jx1h7/1/
Your problem is related to useEffect calling your custom hook again and again, because it's a normal function that React is not "saving" throughout the renders.
UPDATE
My initial answer fixed the infinite loop.
Your problem was also related to the way you use the context, as it recreates the domain objects of your context (actions, state, ..) again and again (See caveats in the official documentation).
Here is your example in Kent C. Dodds' wonderful way of splitting up context into state and dispatch, which I can't recommend enough. This will fix your infinite loop and provides a cleaner structure of the context usage. Note that I'm still using useCallback for the fetch function based on my original answer:
Complete Codesandbox https://codesandbox.io/s/fancy-sea-bw70b
App.js
import React, { useEffect, useCallback } from "react";
import "./styles.css";
import { useItemState, ItemProvider, useItemDispatch } from "./item-context";
const SmallComponent = () => {
const id = 5;
const { username } = useItemState();
const dispatch = useItemDispatch();
const fetchUsername = useCallback(async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/" + id
);
const user = await response.json();
dispatch({ type: "setUsername", usernameUpdated: user.name });
}, [dispatch]);
useEffect(() => {
fetchUsername();
}, [fetchUsername]);
return (
<div>
<h4>Username from fetch:</h4>
<p>{username || "not set"}</p>
</div>
);
};
export default function App() {
return (
<div className="App">
<ItemProvider>
<SmallComponent />
</ItemProvider>
</div>
);
}
item-context.js
import React from "react";
const ItemStateContext = React.createContext();
const ItemDispatchContext = React.createContext();
function itemReducer(state, action) {
switch (action.type) {
case "setUsername": {
return { ...state, username: action.usernameUpdated };
}
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
}
}
function ItemProvider({ children }) {
const [state, dispatch] = React.useReducer(itemReducer, {
username: "initial username"
});
return (
<ItemStateContext.Provider value={state}>
<ItemDispatchContext.Provider value={dispatch}>
{children}
</ItemDispatchContext.Provider>
</ItemStateContext.Provider>
);
}
function useItemState() {
const context = React.useContext(ItemStateContext);
if (context === undefined) {
throw new Error("useItemState must be used within a CountProvider");
}
return context;
}
function useItemDispatch() {
const context = React.useContext(ItemDispatchContext);
if (context === undefined) {
throw new Error("useItemDispatch must be used within a CountProvider");
}
return context;
}
export { ItemProvider, useItemState, useItemDispatch };
Both of these blog posts helped me a lot when I started using context with hooks initially:
https://kentcdodds.com/blog/application-state-management-with-react
https://kentcdodds.com/blog/how-to-use-react-context-effectively
OK, I didn't want to write an answer as Bennett basically gave you the fix, but I think it is missing the part in the component, so here you go:
const ItemProvider = ({ children }) => {
const [item, setItem] = useState(null)
const findById = useCallback((args = {}) => {
fetch('http://....', { method: 'POST' }).then((newItem) => setItem(newItem))
}, []);
return (
<ItemContext.Provider value={{ actions: { findById }, state: { item } }}>
{children}
</ItemContext.Provider>
)
}
const smallComponent = () => {
const { id } = useParams()
const { actions } = useContext(ItemContext)
useEffect(() => {
itemContext.actions.findById(id)
}, [actions.findById, id])
return <div>info here</div>
}
Extended from the comments, here's the working JSFiddle

How can I initialize in useState with the data from custom hooks?

I'm learning to React Hooks.
And I'm struggling initialize data that I fetched from a server using a custom hook.
I think I'm using hooks wrong.
My code is below.
const useFetchLocation = () => {
const [currentLocation, setCurrentLocation] = useState([]);
const getCurrentLocation = (ignore) => {
...
};
useEffect(() => {
let ignore = false;
getCurrentLocation(ignore);
return () => { ignore = true; }
}, []);
return {currentLocation};
};
const useFetch = (coords) => {
console.log(coords);
const [stores, setStores] = useState([]);
const fetchData = (coords, ignore) => {
axios.get(`${URL}`)
.then(res => {
if (!ignore) {
setStores(res.data.results);
}
})
.catch(e => {
console.log(e);
});
};
useEffect(() => {
let ignore = false;
fetchData(ignore);
return () => {
ignore = true;
};
}, [coords]);
return {stores};
}
const App = () => {
const {currentLocation} = useFetchLocation();
const {stores} = useFetch(currentLocation); // it doesn't know what currentLocation is.
...
Obviously, it doesn't work synchronously.
However, I believe there's the correct way to do so.
In this case, what should I do?
I would appreciate if you give me any ideas.
Thank you.
Not sure what all the ignore variables are about, but you can just check in your effect if coords is set. Only when coords is set you should make the axios request.
const useFetchLocation = () => {
// Start out with null instead of an empty array, this makes is easier to check later on
const [currentLocation, setCurrentLocation] = useState(null);
const getCurrentLocation = () => {
// Somehow figure out the current location and store it in the state
setTimeout(() => {
setCurrentLocation({ lat: 1, lng: 2 });
}, 500);
};
useEffect(() => {
getCurrentLocation();
}, []);
return { currentLocation };
};
const useFetch = coords => {
const [stores, setStores] = useState([]);
const fetchData = coords => {
console.log("make some HTTP request using coords:", coords);
setTimeout(() => {
console.log("pretending to receive data");
setStores([{ id: 1, name: "Store 1" }]);
}, 500);
};
useEffect(() => {
/*
* When the location is set from useFetchLocation the useFetch code is
* also triggered again. The first time coords is null so the fetchData code
* will not be executed. Then, when the coords is set to an actual object
* containing coordinates, the fetchData code will execute.
*/
if (coords) {
fetchData(coords);
}
}, [coords]);
return { stores };
};
function App() {
const { currentLocation } = useFetchLocation();
const { stores } = useFetch(currentLocation);
return (
<div className="App">
<ul>
{stores.map(store => (
<li key={store.id}>{store.name}</li>
))}
</ul>
</div>
);
}
Working sandbox (without the comments) https://codesandbox.io/embed/eager-elion-0ki0v

Resources