Home > OS >  Custom hook returning stale value only in some components
Custom hook returning stale value only in some components

Time:01-30

I have a custom hook for helping manage user settings in regards to certain types of filters. If the user is logged in, the hook uses redux (and associated API calls) to communicate with the api and use redux as the source of truth. If user is not logged in, the hook uses localStorage to manage what filters are currently being used.

The issue I am having is that in one component (the form where users select filters), the return value of my hook is always up to date and correct, regardless of whether using redux or localStorage. However, in others, the return value is stale, when using localStorage (not when using redux). For example:

const Form = () => {
  const { filters, setFilters } = useFilters();

  // value of `filters` is always correct and up to date here
}
const SomewhereElse = () => {
  const { filters } = useFilters();

  // value never changes from after initial mount
}

Here is the hook itself:

import { useLocalStorage } from "usehooks-ts";

/**
 * Custom hook to help get and set filters, with different
 * effects based on whether or not the user is logged in.  If logged in,
 * filters are read from and write to redux store and api.  If not logged in,
 * filters are read from and write to localstorage
 */
export const useFilters = () => {
    const dispatch = useDispatch();

    const authenticated = useSelector(
        (state: ApplicationState) => state.user.auth.authenticated
    );
    const activeFiltersRedux = useSelector(
        (state: ApplicationState) => state.user.info?.settings?.filters
    );

    const [localFilters, setLocalFilters] = useLocalStorage(
        "localFilters",
        JSON.stringify(defaultUserFilters)
    );
    const activeFiltersLocal: { [key: string]: string[] } =
        JSON.parse(localFilters);

    /**
     * If user is authenticated, we use the filters defined in the redux store, which are
     * fetched from the back end for logged-in users.  If not logged in, we can keep
     * track of the user's preferences on their device with localStorage
     */
    const activeFilters = authenticated
        ? activeFiltersRedux ?? defaultUserFilters
        : activeFiltersLocal;

    /**
     * Create local state object to determine what filters are selected.  Automatically loads
     * user's preferences from store.  UI affects this state variable directly, and useEffect below
     * will cause API call to get made to update settings in API.  This doubling of state
     * is done so that the UI is responsive (read from local state), but then still communicates with
     * API.  If we read and wrote directly to API, there is a delay in the UI updating which
     * filters are checked vs not checked as we wait for the api to response and update the store
     */
    const [filters, setFilters] = useState(activeFilters);

    console.log(filters); // this is always correct

    /**
     * Effect intended to sync local state with remote settings from API on mount.
     *
     * If user is authenticated, once activeFilters is defined, set the local state
     * to those filters.  When user makes a change, it will update `filters`, which will
     * then call the other effect.  That will call the api, which will update `activeFilters` to match
     * `filters`, and this effect should not run.
     */
    useEffect(() => {
        if (authenticated && !isEqual(filters, activeFilters)) {
            setFilters(activeFilters);
        }
    }, [activeFilters, authenticated]);

    /**
     * When local state for what is selected updates, send API call to update settings in user.
     * The saga triggered by updateSettings will update state.user.info, which will
     * For unauthenticated users, we have to manually call action to get new map data.
     */
    useEffect(() => {
        if (authenticated) {
            /* If user is authenticated, we update the settings via API */
            dispatch(
                ActionCreators.updateSettings({
                    filters,
                })
            );
        } else {
            /* If not authenticated, we update the settings via localstorage */
            setLocalFilters(JSON.stringify(filters));
        }
    }, [filters, authenticated]);

    return { filters, setFilters };
};

All is working as expected when logged in and reading from redux. However, when not logged in, certain components don't seem to update properly when reading the filters property returned by useFilters. Meaning, when the page load, localStorage is ready, and filters is set to that value. But on subsequent updates through the UI, that value always stays the same. Note that when logging filters within the hook, it is always up to date, and logs fresh values when things change. But when consumed as const { filters } = useFilters(), the value never changes from its initial value.

(Note that useLocalStorage is from the usehooks-ts package, which under the hood, dispatches events and sets state to make sure all consumers of that value update properly.)

When I consume useLocalStorage("localFilters") locally within SomewhereElse, it also works as expected, but I need to use these filters in several places, and I want to centralize the logic for choosing between redux and localStorage in the hook, and consume the result in various components via the hook.

Why would the return value be correct in one component, but stale in another?

CodePudding user response:

That's because the state returned from custom hook is NOT shared between each hook call in different components.

From React docs

Do two components using the same Hook share state? No. Custom Hooks are a mechanism to reuse stateful logic (such as setting up a subscription and remembering the current value), but every time you use a custom Hook, all state and effects inside of it are fully isolated.

In Form, you call setFilters, so it updates its own filters value and returns correctly, but that changes are never reflected in SomeOtherComponent since it has its own filters state.

The reason why using useLocalStorage("localFilters") worked is because the value is taken directly from localStorage, which is always kept up-to-date by this line in your useEffect

 setLocalFilters(JSON.stringify(filters));

My guess is to try removing the condition here, so that whenever localStorage updates, local filters is also updated.

  useEffect(() => {
    // remove `authenticated` condition
    if (!isEqual(filters, activeFilters)) {
      setFilters(activeFilters)
    }
  }, [activeFilters, authenticated])

But this is only a workaround and you may want to refactor your hook to keep the state in just one single source of truth (redux in this case) by syncing the localStorage value in redux store

  • Related