Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSR issues with Next.js (and persisting the data) #324

Closed
gino opened this issue Mar 7, 2021 · 23 comments
Closed

SSR issues with Next.js (and persisting the data) #324

gino opened this issue Mar 7, 2021 · 23 comments

Comments

@gino
Copy link

gino commented Mar 7, 2021

Hi there,

I am loving the way that Zustand works and absolutely enjoy using it, but unfortunately I have issues with the following scenario:

  • I have a "user store" that is a Zustand store that saves the "selected user ID".
  • I have a couple of buttons that allows me to select a specific user (but the ID only) and saves it into the store.

Then I am trying to display the selected user based on the ID that is stored in the store. Through a array.find(), where I loop through my users array. Everything works fine so far, but as soon as I want to display a property from that selected user, I get the following error when I refresh the page:

image

This error only happens whenever I refresh the page so it must be an issue with the "persist data" functionality.
I have made a Codesandbox to reproduce this issue and I hope someone is able to help me out with this.

https://codesandbox.io/s/nextjs-zustand-example-z8ujs?file=/pages/index.tsx

Thanks a lot and I hope someone is able to help me out.

Edit: Apparently this issue doesn't even come from the part that filters the users based on the ID. It also throws the error in the console from only showing the "selected user ID" directly from the store, that is persisted. Really strange issue.

@gino
Copy link
Author

gino commented Mar 8, 2021

Edit: I figured out that I could fix this issue by wrapping it into its own component and then dynamically loading it with SSR disabled. But I am not sure if this is the only solution..

@AnatoleLucet
Copy link
Collaborator

Hi 👋. The storage is only available on the client, so on the server (while SSR) the store will have the default values.
React is giving you a warning because the post rehydration VDOM didn't match the SSRed DOM.

If you want to fix this warning you must not set the value in the DOM while SSR. For example, it can be done with a simple condition based on this persist option.

@AnatoleLucet
Copy link
Collaborator

#284 doesn't seem to work though 😕

@sandren
Copy link

sandren commented Mar 13, 2021

If you want to fix this warning you must not set the value in the DOM while SSR. For example, it can be done with a simple condition based on this persist option.

Is there an example of what this looks like? 🤔

@AnatoleLucet
Copy link
Collaborator

@sandren ah, my bad. This part: "it can be done with a simple condition based on this persist option" is wrong. I thought it'd be sufficient, but apparently it isn't.
I've forked @gino's csb: https://codesandbox.io/s/nextjs-zustand-example-forked-bnpfz?file=/pages/index.tsx
The trick is the useHasHydrated hook. If you conditionally render elements based on your store using this hook, you won't have the warning.

@AnatoleLucet
Copy link
Collaborator

I'll close this for now. @gino let me know if your issue isn't solved 🙂

@Saulius-HALO
Copy link

@AnatoleLucet I'd suggest guys to add this into the README in the persist section. I see a lot of questions being raised regarding this. And the sandbox is really helpful. Thanks!

@AnatoleLucet
Copy link
Collaborator

@Saulius-HALO I agree. Though we've planned to create a gh wiki for persist (#300), would probably be better to add a section there instead of filling the readme.

@Saulius-HALO
Copy link

@AnatoleLucet would you be so kind and provide or edit the codesandbox with example on how to incorporate deserialize?

@AnatoleLucet
Copy link
Collaborator

@Saulius-HALO sure.
For example, let's say you want to encode your state in base64 before storing it:

const useMyStore = create(storage(
  ...,
  {
    name: "my-store",
    serialize: (state) => btoa(JSON.stringify(state)),
    deserialize: (storedState) => JSON.parse(atob(storedState)),
  }
))

https://codesandbox.io/s/nextjs-zustand-example-forked-bnpfz?file=/store/useUserStore.ts

@sidneypp
Copy link

Isn't there a way to save this information also on the server side?

I found a form with cookies, but I would have to use GetServerSideProps on every page I needed and I don't want that.

@AnatoleLucet
Copy link
Collaborator

@sidneypp Although this is a bit hacky, I think it should work with some kind of custom storage.

const useMyStore = create(storage(
  ...,
  {
    name: "my-store",
    getStorage: () => ({
      setItem: (key, value) => {
        // your own logic
      }, 
      getItem: (key) => {
        // still your own logic
      },
    })
  }
))

@UlisseMini
Copy link

UlisseMini commented Feb 2, 2022

@sandren ah, my bad. This part: "it can be done with a simple condition based on this persist option" is wrong. I thought it'd be sufficient, but apparently it isn't. I've forked @gino's csb: https://codesandbox.io/s/nextjs-zustand-example-forked-bnpfz?file=/pages/index.tsx The trick is the useHasHydrated hook. If you conditionally render elements based on your store using this hook, you won't have the warning.

Is there a way to make this work by default? ie. something like

const myThing = useStoreSSR(state => state.myThing)
return <RenderMyThing myThing={myThing} />

Where useStoreSSR will initially render using the default state value, then re-renders with persisted data in a useEffect or something. I know how to do this for a specific myThing

const [myThing, setMyThing] = useState(defaultThing)
useEffect(() => setMyThing(getMyThingFromStorage()), [])
return <RenderMyThing myThing={myThing} />

But writing the generic useStateSSR escapes me. I feel this would be useful to other people since this is how I expected zustand with persistence and react to behave (ie. it hydrates the page that rendered with the default state).

I specifically want this because it leads to cleaner code, say I have

const postsRead = useStore(state => state.postsRead)

return <ul>
  {posts.map(post =>
    <li style={color: postsRead.includes(post.slug) ? "green" : "yellow"}>{post.title}</li>
  )}
</ul>

This code (which is the obvious thing to write) leads to hydration mismatches and wierd bugs (don't ask or you'll trigger my ptsd). I feel this is the opposite of the pit of success

It's perfectly fine to SSR this with postsRead = [] then hydrate on the client, but with the useHasHydrated trick I'd have to write

const hydrated = useHasHydrated()
const postsReadZustand = useStore(state => state.postsRead)
const postsRead = hydrated ? postsReadZustand : []

return <ul>{posts.map(...)}</ul>

Which isn't much worse, but I feel like this should "just work" and not require boilerplate and duplicating the defaults.

Edit: I managed to sort of implement useStoreSSR, it's really ugly though

// Separation is needed because we can't instantiate
// state functions without a reference to set and get
type StateData = { posts: []Post };
type StateFunctions = { /* ... */ };
type State = StateData & StateFunctions;

const defaultState: StateData = { posts: [] };

export const useStoreSSR = <U>(selector: StateSelector<StateData, U>) => {
  const defaultValue = selector(defaultState);
  const [value, setValue] = useState(defaultValue);
  const zustandValue = useStore(selector);

  useEffect(() => setValue(zustandValue), []);
  return value;
};

Now the hydration error is gone, but if it's this annoying to write useStoreSSR I'll probably go with the duplicated defaults / explicit mode

@AnatoleLucet
Copy link
Collaborator

@UlisseMini as shown in the doc, this is intentional.

One way to get the behavior you want is to use an asynchronous storage.
You could create your own reusable persist async middleware by promisifying your storage (as shown here) :

export const asyncPersist = (config, options) => {
  const { getStorage } = options;

  options.getStorage = () => {
    const { setItem, getItem, removeItem } = getStorage();
    
    return {
      setItem: async (...args) => setItem(...args),
      getItem: async (...args) => getItem(...args),
      removeItem: async (...args) => removeItem(...args),
    }
  };

  return persist(config, options);
};


const useStore = create(asyncPersist(
  (set, get) => ({
    ...
  }),
  {
    ...
  }
))

@IRediTOTO
Copy link

Hmm how to solve this...

@larsqa
Copy link

larsqa commented Sep 1, 2022

@UlisseMini as shown in the doc, this is intentional.

One way to get the behavior you want is to use an asynchronous storage. You could create your own reusable persist async middleware by promisifying your storage (as shown here) :

export const asyncPersist = (config, options) => {
  const { getStorage } = options;

  options.getStorage = () => {
    const { setItem, getItem, removeItem } = getStorage();
    
    return {
      setItem: async (...args) => setItem(...args),
      getItem: async (...args) => getItem(...args),
      removeItem: async (...args) => removeItem(...args),
    }
  };

  return persist(config, options);
};


const useStore = create(asyncPersist(
  (set, get) => ({
    ...
  }),
  {
    ...
  }
))

I don't get why the hate for this comment. @AnatoleLucet is simply highlighting the default behaviour of the store and showcases two solutions:

However, may I ask, is there a way to use zustand + persist in a hydration sensitive setup like with Next.js, without having to check for hydration every time a value is used?

Next.js outlines a solution with useState and useEffect hook, where the final value is set after it's available? I.e. making persist use useEffect or some other lifecycle feature. The docs

What do you think @AnatoleLucet ?

@m3c-ode
Copy link

m3c-ode commented Oct 13, 2022

Edit: I figured out that I could fix this issue by wrapping it into its own component and then dynamically loading it with SSR disabled. But I am not sure if this is the only solution..

Hi @gino , could you please show an example of exactly what you ended up wrapping for this?

@alvinlys
Copy link

Edit: I figured out that I could fix this issue by wrapping it into its own component and then dynamically loading it with SSR disabled. But I am not sure if this is the only solution..

Hi @gino , could you please show an example of exactly what you ended up wrapping for this?

below code works for me.

import dynamic from 'next/dynamic';

const NonSSRWrapper = ({ children }) => (<>{children}</>);

const ComponentWithNoSSR = dynamic(() => Promise.resolve(NonSSRWrapper), {
    ssr: false,
    loading: () => <p>Loading...</p>,
});

const App = ({ Component, pageProps }) => {
    return (
        <>
            <ComponentWithNoSSR>
                <Component {...pageProps} />
            </ComponentWithNoSSR>
        </>
    );
};

export default App;

@MariusVB
Copy link

@alvin30595 your solution worked for me! Thanks.

@IonelLupu
Copy link

getStorage is deprecated now. What is the new solution? @MariusVB @larsqa @AnatoleLucet

@IonelLupu
Copy link

IonelLupu commented Jun 5, 2023

@MariusVB , @alvinlys 's solution disables SSR all together making the app show a "Loading" indicator instead of showing the actual page. This is not ideal. Not using this solution displays the correct rendered page.

Also, the getStorage solution doesn't work. the options.getStorage() function is undefined

@nickmarca
Copy link

nickmarca commented Dec 6, 2023

useHasHydrated seems to work just fine.

I don't fully understand how the hydration process occur, It seems like it's happening twice here. How could Zustand alter the template before the application is hydrated? In my understanding the page is just as static as a text file before hydration but apparently that's not the case.

Anyway, in my option it does make sense to wait for Next.js to do whatever it has to do first and then update the template with data stored in the local storage.

I found this implementation version which looks nicer:

function useHasHydrated(beforePaint = true) {
  const [hasHydrated, setHasHydrated] = useState(false);

  // To reduce flicker, we use `useLayoutEffect` so that we return value
  // before React has painted to the browser.
  // React currently throws a warning when using useLayoutEffect on the server so
  // we use useEffect on the server (no-op) and useLayoutEffect in the browser.
  const isServer = typeof window === "undefined";
  const useEffectFn = beforePaint && !isServer ? useLayoutEffect : useEffect;

  useEffectFn(() => {
    setHasHydrated(true);
  }, []);

  return hasHydrated;
}

credits: astoilkov/use-local-storage-state#23 (comment)

@ybelakov
Copy link

ybelakov commented May 6, 2024

Here is an abstaction I created to have the hydrated state in every store

import type { StateCreator } from 'zustand/'
import type { PersistOptions } from 'zustand/middleware'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface HydratedState {
  hydrated: boolean
  onRehydrate: () => void
}

export function createHydratedStorage<T extends object>(
  stateCreator: StateCreator<T>, // Extend the state with HydratedState
  options: PersistOptions<T & HydratedState>,
) {
  // Return type is also extended with HydratedState
  const initialStateCreator: StateCreator<T & HydratedState> = (set, get, api) => {
    const originalState = stateCreator(set, get, api)
    // HydratedState implementation
    const hydratedState: HydratedState = {
      hydrated: false,
      onRehydrate: () => {
        set((state) => ({ ...state, hydrated: true }))
      },
    }
    return { ...originalState, ...hydratedState }
  }

  return create(
    persist(initialStateCreator, {
      ...options,
      migrate: (persistedState) => {
        return persistedState as T & HydratedState
      },
      onRehydrateStorage: () => (state) => {
        if (state) {
          state.onRehydrate()
          if (options.onRehydrateStorage) {
            options.onRehydrateStorage(state)
          }
        }
      },
    }),
  )
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

15 participants