In my react-typescript application, I am trying to use a context provider that encapsulates properties and methods and exposes them for a consumer:
const StockPriceConsumer: React.FC = () => {
const stockPrice = useContext(myContext);
let val = stockPrice.val;
useEffect(() => {
stockPrice.fetch();
}, [val]);
return <h1>{val}</h1>;
};
The problem is the following warning:
React Hook useEffect has a missing dependency: 'stockPrice'. Either
include it or remove the dependency
array. eslint(react-hooks/exhaustive-deps)
To me it does not make any sense to include the stockPrice (which is basically the provider's API) to the dependencies of useEffect. It only makes sense to include actual value of stock price to prevent infinite calls of useEffect's functions.
Question: Is there anything wrong with the approach I am trying to use or can I just ignore this warning?
The provider:
interface StockPrice {
val: number;
fetch: () => void;
}
const initialStockPrice = {val: NaN, fetch: () => {}};
type Action = {
type: string;
payload: any;
};
const stockPriceReducer = (state: StockPrice, action: Action): StockPrice => {
if (action.type === 'fetch') {
return {...state, val: action.payload};
}
return {...state};
};
const myContext = React.createContext<StockPrice>(initialStockPrice);
const StockPriceProvider: React.FC = ({children}) => {
const [state, dispatch] = React.useReducer(stockPriceReducer, initialStockPrice);
const contextVal = {
...state,
fetch: (): void => {
setTimeout(() => {
dispatch({type: 'fetch', payload: 200});
}, 200);
},
};
return <myContext.Provider value={contextVal}>{children}</myContext.Provider>;
};
I would recommend to control the whole fetching logic from the provider:
const StockPriceProvider = ({children}) => {
const [price, setPrice] = React.useState(NaN);
useEffect(() => {
const fetchPrice = () => {
window.fetch('http...')
.then(response => response.json())
.then(data => setPrice(data.price))
}
const intervalId = setInterval(fetchPrice, 200)
return () => clearInterval(intervalId)
}, [])
return <myContext.Provider value={price}>{children}</myContext.Provider>;
};
const StockPriceConsumer = () => {
const stockPrice = useContext(myContext);
return <h1>{stockPrice}</h1>;
};
...as a solution to a couple of problems from the original appproach:
do you really want to fetch only so long as val is different? if the stock price will be the same between 2 renders, the useEffect won't execute.
do you need to create a new fetch method every time <StockPriceProvider> is rendered? That is not suitable for dependencies of useEffect indeed.
if both are OK, feel free to disable the eslint warning
if you want to keep fetching in 200ms intervals so long as the consumer is mounted:
// StockPriceProvider
...
fetch: useCallback(() => dispatch({type: 'fetch', payload: 200}), [])
...
// StockPriceConsumer
...
useEffect(() => {
const i = setInterval(fetch, 200)
return () => clearInterval(i)
}, [fetch])
...
The important concept here is that react compares the objects by reference equality. Meaning that every time the reference (and not the content) changes it will trigger a re-render. As a rule of thumb, you always need to define objects/functions that you want to pass to child components by useCallback and useMemo.
So in your case:
The fetch function will become:
const fetch = useCallback(() => {
setTimeout(() => {
dispatch({ type: 'fetch', payload: 200 });
}, 1000);
}, []);
The empty array means that this function will be only defined when the component is mounted. And then:
let {val, fetch} = stockPrice;
useEffect(() => {
fetch();
}, [val, fetch]);
This means the useEffect's callback will execute only when fetch or val changes. Since fetch will be defined only once, in practice it means only val changes are gonna trigger the effect's callback.
Also, I can imagine you want to trigger the fetch only when isNaN(val) so:
let {val, fetch} = stockPrice;
useEffect(() => {
if(isNaN(val)) {
fetch();
}
}, [val, fetch]);
All that being said, there's a bigger issue with this code!
You should reconsider the way you use setTimeout since the callback can run when the component is already unmounted and that can lead to a different bug. In these cases you should useEffect and clear any async operation before unmounting the component. So here's my suggestion:
import React, { useCallback, useContext, useEffect } from 'react';
interface StockPrice {
val: number;
setFetched: () => void;
}
const initialStockPrice = { val: NaN, setFetched: () => { } };
type Action = {
type: string;
payload: any;
};
const stockPriceReducer = (state: StockPrice, action: Action): StockPrice => {
if (action.type === 'fetch') {
return { ...state, val: action.payload };
}
return { ...state };
};
const myContext = React.createContext<StockPrice>(initialStockPrice);
const StockPriceProvider: React.FC = ({ children }) => {
const [state, dispatch] = React.useReducer(
stockPriceReducer,
initialStockPrice
);
const setFetched = useCallback(() => {
dispatch({ type: 'fetch', payload: 200 });
}, []);
const contextVal = {
...state,
setFetched,
};
return <myContext.Provider value={contextVal}>{children}</myContext.Provider>;
};
const StockPriceConsumer: React.FC = () => {
const stockPrice = useContext(myContext);
const {val, setFetched} = stockPrice;
useEffect(() => {
let handle = -1;
if(isNaN(val)) {
let handle = setTimeout(() => { // Or whatever async operation
setFetched();
}, 200);
}
return () => clearTimeout(handle); // Clear timeout before unmounting.
}, [val, setFetched]);
return <h1>{stockPrice.val.toString()}</h1>;
};
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();
I am trying to set mockPatient data and wanted to test if the 'sortByCaseFn ' function is called by the useEffect.
Here is my sourcecode:
Patient.tsx
const [patients, setPatients] = useState([]);
const [sortBy, setSortBy] = useState('events');
const [fetched, setFetched] = useState(false);
const dispatch = useAppDispatch();
const props = useAppSelector((state) => state.myPatientProps);
const getPatientData = (): void => {
dispatch(MyPatientActions.getMyPatientsData());
};
const sortByCaseFn = (sortBy, list) => {
let patientsToSort = [...list];
if (sortBy.includes('events'))
patientsToSort.sort(
sorter.byPropertiesOf(['-ActiveEventsCount', 'LastName'])
);
if (sortBy.includes('vae'))
patientsToSort.sort(sorter.byPropertiesOf(['-VaeStatus']));
console.log('patientsToSort---', patientsToSort);
setPatients(patientsToSort);
};
useEffect(() => {
if (!fetched) {
getPatientData();
}
}, []);
useEffect(() => {
console.log('setpatients called .. ', patients);
}, [patients]);
useEffect(() => {
const saved_sortby = localStorage.getItem('sortby');
if (saved_sortby) {
sortByCaseFn(saved_sortby, props.myPatientDetails);
} else sortByCaseFn('events', props.myPatientDetails);
setFetched(true);
}, [props.myPatientDetails]);
useEffect(() => {
sortByCaseFn(sortBy, patients);
}, [sortBy]);
return (
<> Render Patient List </> )
My Test Code :
Patients.test.tsx
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
useDispatch: jest.fn()
}));
export const setHookTestState = (newState: any) => {
const setStateMockFn = () => {};
return Object.keys(newState).reduce((acc, val) => {
acc = acc?.mockImplementationOnce(() => [newState[val], setStateMockFn]);
return acc;
}, jest.fn());
};
describe('My Patient Screen', () => {
const useSelectorMock = reactRedux.useSelector as jest.Mock<any>;
const useDispatchMock = reactRedux.useDispatch as jest.Mock<any>;
beforeEach(() => {
useSelectorMock.mockImplementation((selector) => selector(mockStore));
useDispatchMock.mockImplementation(() => () => {});
});
afterEach(() => {
useDispatchMock.mockClear();
useSelectorMock.mockClear();
});
const mockInitialState = {
myPatientDetails: vaeMock,
fetching: false,
failedMsg: '',
requestPayload: {}
};
const mockStore = {
counter: undefined,
menu: undefined,
selectPatientProps: undefined,
myPatientProps: mockInitialState
};
test('validate sorting by events', async (done) => {
React.useState = setHookTestState({
patients: vaeMock,
sortBy: 'vae',
fetched: 'false'
});
const {
getByText,
getByRole,
getByTestId,
getAllByTestId,
findAllByTestId,
queryByText,
container
} = render(<Mypatient />);
await waitFor(() => {
expect(getByText('Ander, Sam')).toBeDefined();
});
const list = getAllByTestId('patientname');
expect(within(list[0]).getByText('Sara, Jone')).toBeInTheDocument(); //Fails here as Sorting doesnt happen
console.log('....list ', list);
});
});
My Observations:
The 'vaeMock' data that I set in redux state 'mockInitialState' is successfully sent as props
The 'vaeMock' data that I set in component state using setHookTestState is also set successfully.
The lifecycle events happens like this -
a. setPatients() is called using the component state data.
b. using props that is sent , sortByCaseFn is called but setPatients is not called.
c. again using the component state , sortByCaseFn is called but setPatients is not set.
Without setting the component state variables runs into a TypeError: Undefined is not iterable.
All Iam trying to do is - send a mockData to a component that uses useDispatch, useEffects
and sort the data on the component mount and initialize to local state variable.
React code
import React, { useEffect, useState } from "react";
import { getDocs, collection } from "firebase/firestore";
import { auth, db } from "../firebase-config";
import { useNavigate } from "react-router-dom";
function Load() {
const navigate = useNavigate();
const [accountList, setAccountList] = useState([]);
const [hasEmail, setHasEmail] = useState(false);
const accountRef = collection(db, "accounts");
Am i using useEffect correctly?
useEffect(() => {
const getAccounts = async () => {
const data = await getDocs(accountRef);
setAccountList(
data.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}))
);
};
getAccounts();
emailCheck();
direct();
}, []);
checking whether email exists
const emailCheck = () => {
if (accountList.filter((e) => e.email === auth.currentUser.email)) {
setHasEmail(true);
} else {
setHasEmail(false);
}
};
Redirecting based on current user
const direct = () => {
if (hasEmail) {
navigate("/index");
} else {
navigate("/enterdetails");
}
};
return <div></div>;
}
The code compiles but doesn't redirect properly to any of the pages.
What changes should I make?
First question posted excuse me if format is wrong.
There are two problems here:
useEffect(() => {
const getAccounts = async () => {
const data = await getDocs(accountRef);
setAccountList(
data.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}))
);
};
getAccounts();
emailCheck();
direct();
}, []);
In order:
Since getAccounts is asynchronous, you need to use await when calling it.
But even then, setting state is an asynchronous operation too, so the account list won't be updated immediately after getAccounts completes - even when you use await when calling it.
If you don't use the accountList for rendering UI, you should probably get rid of it as a useState hook altogether, and just use regular JavaScript variables to pass the value around.
But even if you use it in the UI, you'll need to use different logic to check its results. For example, you could run the extra checks inside the getAccounts function and have them use the same results as a regular variable:
useEffect(() => {
const getAccounts = async () => {
const data = await getDocs(accountRef);
const result = data.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}));
setAccountList(result);
emailCheck(result);
direct();
};
getAccounts();
}, []);
const emailCheck = (accounts) => {
setHasEmail(accounts.some((e) => e.email === auth.currentUser.email));
};
Alternatively, you can use a second effect that depends on the accountList state variable to perform the check and redirect:
useEffect(() => {
const getAccounts = async () => {
const data = await getDocs(accountRef);
setAccountList(
data.docs.map((doc) => ({
...doc.data(),
id: doc.id,
}))
);
};
getAccounts();
});
useEffect(() => {
emailCheck();
direct();
}, [accountList]);
Now the second effect will be triggered each time the accountList is updated in the state.
Anybody has experience in AsyncStorage in React Native? It returns wired values something like this.
"_U": 0,
"_V": 1,
"_X": null,
"_W": {}
And here is Context, useReducer hook code.
const [localState, localDispatch] = useReducer(
local,
localInitialState,
async () => {
await AsyncStorage.removeItem(‘local’);
const storedLocalData = await AsyncStorage.getItem(‘local’);
console.log(‘LOCAL: ’, storedLocalData);
storedLocalData ? console.log(‘LOCAL-YES’) : console.log(‘LOCAL-NO’);
return storedLocalData ? JSON.parse(storedLocalData) : localInitialState;
},
);
const [themeState, themeDispatch] = useReducer(
themeReducer,
themeInitialState,
async () => {
await AsyncStorage.removeItem(‘theme’);
const storedThemeData = await AsyncStorage.getItem(‘theme’);
console.log(‘THEME: ’, storedThemeData);
storedThemeData ? console.log(‘THEME-YES’) : console.log(‘THEME-NO’);
return storedThemeData ? JSON.parse(storedThemeData) : themeInitialState;
},
);
Local state works well but theme sate which copied from local does not work...
And this is Console state.
Local state already stored in Asyncstorage. but Theme state returns null.. 😦
with the same code..
the State should be works like local state. not the theme state.
I hope any advise, Thanks.
Unfortunately there's no possibility for useReducer to have a function that returns a Promise as initializer for now! (which I think it's necessary for the next updates of React)
but here's my solution for now: (written in typescript)
import React from "react";
import { CommonActionTypes } from "context/common/CommonActions";
import useStorage from "./useStorage";
/**
* --- IMPORTANT ----
* if you're using this wrapper, your reducer must handle the ReplaceStateAction
* **Also** your state needs to have a property named `isPersistedDataReady` with `false` as default value
*/
export function usePersistedReducer<State, Action>(
reducer: (state: State, action: Action) => State,
initialState: State,
storageKey: string,
): [State, React.Dispatch<Action>] {
const { value, setValue, isReady } = useStorage<State>(storageKey, initialState);
const reducerLocalStorage = React.useCallback(
(state: State, action: Action): State => {
const newState = reducer(state, action);
setValue(newState);
return newState;
},
[value],
);
const [store, dispatch] = React.useReducer(reducerLocalStorage, value);
React.useEffect(() => {
isReady &&
// #ts-ignore here we need an extension of union type for Action
dispatch({
type: CommonActionTypes.ReplaceState,
state: { ...value, isPersistedDataReady: true },
});
}, [isReady]);
return [store, dispatch];
}
then in your views isPersistedDataReady value.
here's also the implementation of the hook useStorage
import AsyncStorage from "#react-native-async-storage/async-storage";
const useStorage = <T>(key: string, defaultValue: T) => {
type State = { value: T; isReady: boolean };
const [state, setState] = React.useState<State>({
value: defaultValue,
isReady: false,
});
React.useEffect(() => {
get()
.then((value) => {
setState({ value, isReady: true });
})
.catch(() => {
setState({ value: defaultValue, isReady: true });
});
}, []);
React.useEffect(() => {
state.value && state.isReady && save(state.value);
}, [state.value]);
const setValue = (value: T) => {
setState({ value, isReady: true });
};
const save = (value: T): Promise<void> => {
if (value) {
try {
const savingValue = JSON.stringify(value);
return AsyncStorage.setItem(key, savingValue);
} catch (er) {
return Promise.reject(er);
}
} else {
return Promise.reject(Error("No value provided"));
}
};
const get = (): Promise<T> => {
return AsyncStorage.getItem(key, () => defaultValue).then((value) => {
if (value === null) {
throw Error(`no value exsits for ${key} key in the storage`);
}
return JSON.parse(value);
});
};
const remove = (): Promise<void> => {
return AsyncStorage.removeItem(key);
};
return { ...state, setValue, clear: remove };
};
export default useStorage;
I'm implementing a React Todo List with Mobx-State-Tree as a state manager.
from the fetchTodos function I'm calling the setTodos function.
It seems like the code is working,
but I don't understand why it shows me the following error:
export const TodosStore = types
.model("TodoStore", {
todos: types.array(TodoModel)
}).actions(store => ({
setTodos(newTodos) {
store.todos = newTodos;
},
async fetchTodos() {
const response = await fetch(`${BASE_URL}todos`);
const data = await response.json();
const newTodos = data.map(todo => ({
id: todo.id,
title: todo.title,
description: todo.description ? todo.description : ""
}))
store.setTodos(newTodos);
},
}));
At that stage setTodos is not yet bound to the current instance, because both setTodos and fetchTodos are in the same closure. So you need to declare them as separate functions and then return an object containing this functions, like that:
.actions(self => {
const setTodos = () => {
// ...
}
const fetchTodos = async => {
// ...
// here you can use setTodos now
}
return {
setTodos,
fetchTodos
}
})