astoilkov / use-local-storage-state

React hook that persists data in localStorage
MIT License
1.09k stars 39 forks source link

Opt-out of cross-tab syncing? #24

Closed jesstelford closed 3 years ago

jesstelford commented 3 years ago

This library is fantastic, thank you!

I've come across a use-case where I need to opt-out of the cross-tab syncing, would it be possible to get a config option for this?

Here's the part I'm talking about:

https://github.com/astoilkov/use-local-storage-state/blob/master/src/useLocalStorageStateBase.ts#L80-L93

My use-case

I'm using use-local-storage-state to track recent URL paths visited.

For example, I'll push each path onto an array such as: ['/about', '/blog', '/contact'], etc.

I then want to update the "Recent Path" on page load, so I do it in a useEffect:

import { useEffect } from 'react'
import { createLocalStorageStateHook } from 'use-local-storage-state'

const useLocalStorage = createLocalStorageStateHook('recent-paths', [])

// `path` is derived from the current URL
export const useRecentPathsTracker = (path) => {
  const [recentPaths, setRecentPaths] = useLocalStorage()
  useEffect(() => {
    // Make sure `path` is de-duplicated and prefixed to the start of the array
    setRecentPaths([
      path,
      ...(recentPaths || []).filter(recentPath => recentPath !== path),
    ])
  }, [path, recentPaths, setRecentPaths])
}

This works fine in a single tab, but when I open 2 tabs that point to different paths (for example; example.com/blog & example.com/about), the following happens:

  1. Open example.com/blog in Tab 1
  2. The useEffect calls setRecentPaths(['/blog'])
  3. Open example.com/about in Tab 2
  4. The useEffect sets setRecentPaths(['/about', '/blog'])
  5. Calling setRecentPaths in Tab 2 updates the window's storage item, which triggers an event in Tab 1
  6. Tab 1's useEffect sees a new value for recentPaths, so runs again and calls setRecentPaths(['/blog', '/about'])
    • Note the blog & about have swapped
  7. Calling setRecentPaths in Tab 1 updates the window's storage item, which triggers an event in Tab 2
  8. Tab 2's useEffect sees a new value for recentPaths, so runs again and calls setRecentPaths(['/about', '/blog'])
    • Note the blog & about have swapped back again
  9. And so on...

I don't actually need the cross-tab syncing for this usage, so would be happy with something like a { sync: false } option:

const useLocalStorage = createLocalStorageStateHook('recent-paths', [], { sync: false })

The workaround for now is to use the functional form of the setRecentPaths call:

export const useRecentPathsTracker = (path) => {
  const [, setRecentPaths] = useLocalStorage()
  useEffect(() => {
    // NOTE: We use the functional version of state setting here to avoid an
    // infinite loop when two tabs are open with different values for
    // `path`. This is a problem because `use-local-storage-state` will sync the
    // value across tabs by subscribing to the `session` window event.  If we
    // were to add `recentPaths` to the dependency list of `useEffect`, it would
    // trigger a re-evaluation of the effect when either tab altered value and
    // so the tabs would fight about setting their own path to be the first in
    // the list.
    // But by using the functional style, it's not a dependency, and our effect
    // is only executed when this page's `path` changes, which is what we expect
    setRecentPaths(recentPaths => {
      return [
        path,
        ...(recentPaths || []).filter(recentPath => recentPath !== path),
      ]
    })
  }, [path, setRecentPaths]) // Only run once per page load
}

The end result is the setRecentPaths is only called once per mount, which is what I expected initially.

astoilkov commented 3 years ago

Isn't it true that if you use useRecentPathsTracker() at two places in your code the issue will appear without needing two tabs? This makes me think that the problem is not the syncing but that the useEffect() updates the value every time it changes. Using setRecentPaths(recentPaths => {}) seems like a logical solution. Another way of fixing this problem is by making the useEffect() run only on initial load by providing [] as a second parameter.

What I want to say is that it doesn't seem to be a problem with the syncing in particular but with the way you update the values.

Also, correct me if I am wrong but the first version of your code will create an infinite loop. The reason why it isn't blocking is that it uses useEffect(). If you change it to useLayoutEffect() it will hang your app.

astoilkov commented 3 years ago

I'm closing this issue for lack of activity.

@jesstelford If you have any more thoughts on this I will be happy to reopen this issue.

dalmo3 commented 3 years ago

I wrote a custom hook to achieve tab independency* by only reading from local storage on mount, but still writing to it:

const useLocalStorageStateOnMount: typeof useLocalStorageState = (
  key,
  defaultValue?
) => {
  const [inMemoryState, setInMemoryState] = useState(defaultValue);
  const [storedState, setStoredState, props] = useLocalStorageState(
    key,
    defaultValue
  );
  useEffect(() => setInMemoryState(storedState), []);

  const setState: typeof setInMemoryState = (value) => {
    setInMemoryState(value);
    setStoredState(value);
  };

  return [inMemoryState, setState, props];
};

*Of course that's not exactly session independency, it syncs on mount so navigation can still affect it, but it prevents tabs 'talking' to each other like the OP's issue.

astoilkov commented 3 years ago

@dalmo3, can you share what is your use case? Why do you want to opt out of cross tab syncing?

dalmo3 commented 3 years ago

I have some tables that I can filter/sort and wanted to visualise them side-by-side with different filters on, while still saving the last state.

To be fair, given the caveat I mentioned about navigation, I still needed to handle unmounting so I ended up employing a mix of sessionStorage and localStorage, but that might digress too much from the original issue.

astoilkov commented 3 years ago

Thanks for the info.

Just a quick question about your use case. If you have two tables, do you bring both tables back when the user reopens the webpage? Because in this case you can assign ids for each table and keep both filters in localStorage. Just an idea.

dalmo3 commented 3 years ago

Sorry, I wasn't clear. The side-by-side view was for the same table, with different filters applied.

astoilkov commented 3 years ago

How are they side-by-side and still be the same table? Do you have two windows opened side-by-side(split-screen) on the same page?

dalmo3 commented 3 years ago

Yes, it's two tabs (that's the point of this whole thread)... How I organise the windows is irrelevant.

astoilkov commented 3 years ago

Yep, you are right. Sorry. I might have asked too many irrelevant questions. Your use case makes sense. I will think about it more. Thanks!