I wrote a custom context provider in the below which holds application settings as a Context
as well as save it in localStorage
(thanks to a post by Alex Krush).
I added initialized
flag to avoid saving the value fetched from localStorage
right after the component is mounted (useEffect
will be run at the timing of componentDidMount
and try to write the fetched value to the storage).
import React, { useCallback, useEffect, useReducer, useRef } from 'react';
const storageKey = 'name';
const defaultValue = 'John Doe';
const initializer = (initialValue) => localStorage.getItem(storageKey) || initialValue;
const reducer = (value, newValue) => newValue;
const CachedContext = React.createContext();
const CachedContextProvider = (props) => {
const [value, setValue] = useReducer(reducer, defaultValue, initializer);
const initialized = useRef(false);
// save the updated value as a side-effect
useEffect(() => {
if (initialized.current) {
localStorage.setItem(storageKey, value);
} else {
initialized.current = true; // skip saving for the first time
}
}, [value]);
return (
<CachedContext.Provider value={[value, setValue]}>
{props.children}
</CachedContext.Provider>
);
};
Usage:
const App = (props) => {
return <CachedContextProvider><Name name='Jane Doe' /></CachedContextProvider>;
}
const Name = (props) => {
const [name, setName] = useContext(CachedContext);
useEffect(() => {
setName(props.name);
}, [props.name]);
}
Then, I'd like to make my custom context detect changes to the target storage made by another window. I added handleStorageEvent
to CachedContextProvider
for listening storage events:
// re-initialize when the storage has been modified by another window
const handleStorageEvent = useCallback((e) => {
if (e.key === storageKey) {
initialized.current = false; // <-- is it safe???
setValue(initializer(defaultValue));
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageEvent);
return () => {
window.removeEventListener('storage', handleStorageEvent);
};
}
}, []);
My concern is whether I can reset initialized
to false
safely for avoid writing back the fetched value. I'm worried about the following case in the multi-process setting:
setValue('Harry Potter')
setValue('Harry Potter')
localStorage.setItem
in response to update on value
handleStorageEvent
in Window 1 detects the change in storage and re-initialize its initialized
and value
as false
and 'Harry Potter'
localStorage.setItem
, but it does nothing because value
is already set as 'Harry Potter'
by Window 2 and React may judge there is no changes. As a result, initialized
will be kept as false
setValue('Ron Weasley')
. It updates value
but does not save it because initialized === false
. It has a chance to lose the value set by the applicationI think it is related to the execution order between React's useEffect
and DOM event handler. Does anyone know how to do it right?
I discussed the problem with my colleague and finally found a solution. He pointed out that new React Fiber engine should not ensure the order of execution of side-effects, and suggested adding a revision number to the state.
Here is an example. The incremented revision
will always invoke useEffect
even if the set value
is not changed. Subscribers obtain state.value
from Provider
and don't need to concern about the underlying revision
.
import React, { useCallback, useEffect, useReducer, useRef } from 'react';
const storageKey = 'name';
const defaultValue = 'John Doe';
const orDefault(value) = (value) =>
(typeof value !== 'undefined' && value !== null) ? value : defaultValue;
const initializer = (arg) => ({
value: orDefault(localStorage.getItem(storageKey)),
revision: 0,
});
const reducer = (state, newValue) => ({
value: newValue,
revision: state.revision + 1,
});
const useCachedValue = () => {
const [state, setState] = useReducer(reducer, null, initializer);
const initialized = useRef(false);
// save the updated value as a side-effect
useEffect(() => {
if (initialized.current) {
localStorage.setItem(storageKey, state.value);
} else {
// skip saving just after the (re-)initialization
initialized.current = true;
}
}, [state]);
// re-initialize when the storage has been modified by another window
const handleStorageEvent = useCallback((e) => {
if (e.key === null || e.key === storageKey) {
initialized.current = false;
setState(orDefault(e.newValue));
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleStorageEvent);
return () => {
window.removeEventListener('storage', handleStorageEvent);
};
}
}, []);
return [state.value, setState];
};
const Context = React.createContext();
const Provider = (props) => {
const cachedValue = useCachedValue();
return (
<Context.Provider value={cachedValue}>
{props.children}
</Context.Provider>
);
};