javascriptreactjslocal-storageweb-storage

Execution order between React's useEffect and DOM event handler


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:

  1. Window 1 runs setValue('Harry Potter')
  2. Window 2 runs setValue('Harry Potter')
  3. Window 2 runs localStorage.setItem in response to update on value
  4. handleStorageEvent in Window 1 detects the change in storage and re-initialize its initialized and value as false and 'Harry Potter'
  5. Window 1 try to run 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
  6. Window 1 runs 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 application

I think it is related to the execution order between React's useEffect and DOM event handler. Does anyone know how to do it right?


Solution

  • 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>
      );
    };