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 Example (SSR data is shared) #182

Closed
jd327 opened this issue Sep 7, 2020 · 93 comments · Fixed by #375
Closed

SSR Example (SSR data is shared) #182

jd327 opened this issue Sep 7, 2020 · 93 comments · Fixed by #375
Labels
help wanted Please someone help on this

Comments

@jd327
Copy link

jd327 commented Sep 7, 2020

I'm using Next.js and have a separate file state.js that has something like this:

export const useSessionStore = create(combine({
  session: {},
}, set => ({
  setSession: (session) => set({ session }),
})))

And then calling setSession() on the server (for example in _app.js) after a successful login. When reading session on the server + client, it seems to work fine. So far so good.

The problem is: the session seems to be saved in Node.js memory and shared with everyone(?). When you login on one browser, the same session data is returned when you load the page on another browser. So I'm assuming when I set session on the server, it persists on Node.js side and returns the same session for all clients.

What am I doing wrong, or is zustand not meant for this?

@dai-shi
Copy link
Member

dai-shi commented Sep 7, 2020

The useSessionStore is a store itself as well as a hook. So, it's a singleton. A singleton can't be used for such session data. I'd say it's not meant for this, but other comments from someone are welcome.

@jd327
Copy link
Author

jd327 commented Sep 7, 2020

@dai-shi Thanks for taking a look.

Yeah I see some Next.js example attempts, but nothing official. Can anybody confirm if this is a no-go for SSR? What do others do/use?

@dai-shi dai-shi added the help wanted Please someone help on this label Sep 7, 2020
@dai-shi
Copy link
Member

dai-shi commented Sep 9, 2020

@jd327 Does SSR work if a use case allow to have data shared? What are the examples? Pointers are welcome.

We need to understand how such a library like zustand supports SSR, but personally if there's a way, we would like to add a support for it.

@jd327
Copy link
Author

jd327 commented Sep 9, 2020

@dai-shi Thanks for following up.

A bit of context: for this new Next.js app, I was hoping to avoid having one global Redux state. If I could just use Zustand hooks alone throughout the app, that would be a big improvement in simplicity. Previously, I've had to use this: https://github.com/kirill-konshin/next-redux-wrapper

From what I see so far there's an issue with the way state data is stored/transferred from server to client when using Zustand. When I set data on the server, it seems to persist between requests (another browser will get the same data), which I am guessing means it is stored somewhere in Node.js memory and shared..?

Ideally, I'd just like it to transfer from server to client per request. Example: server reads session cookie > server pulls data from somewhere > server calls setSession(session) > server sends html to the client > client can use session.

@dai-shi
Copy link
Member

dai-shi commented Sep 9, 2020

it is stored somewhere in Node.js memory and shared..?

Yes, this is true. But, it must be true for Redux too.
So, probably next-redux-wrapper does some job. I'd take a deeper look later.

We definitely need some more people to help on this issue.

@jd327
Copy link
Author

jd327 commented Sep 9, 2020

That'd be super helpful. I'm currently supplementing with createContext and useContext for anything SSR-related and zustand for purely client-side, and I'm afraid I'm slowly making a mess of it.

But, it must be true for Redux too.

Perhaps @kirill-konshin could chime in on what the magic is?

Edit: Just fyi, there was also a nextjs example repo attempt, but looks like it was never finalized.

@kirill-konshin
Copy link

next-redux-wrapper does lots of things behind the scenes. It wraps Next.js hook methods and properly hydrates the state between client and server in various navigation scenarios and hook combinations. Unfortunately, I'm not familiar with Zustand, so I doubt I can help here.

@dai-shi dai-shi mentioned this issue Sep 9, 2020
@jd327
Copy link
Author

jd327 commented Sep 9, 2020

@kirill-konshin Thank you for the info!


@dai-shi Here's a little example repo, which is approximately what I started with: https://codesandbox.io/s/jovial-germain-vyvxo. I'm not sure their preview window will work, you might have to copy files locally.

To reproduce:

  1. npm install && npm start

  2. Navigate to http://localhost:3000/login (make sure this is your first page-load)

  3. Click on the "go back" link to go to the homepage

  4. Now refresh the page. You should see the console display the following warning:

Text content did not match. Server: "are" Client: "are not"

This is where it seems the logged-in "session" seems to be stored on the server and shared across requests. Because otherwise, the user would always be logged-out on hard refresh. Also if you open a new browser and go to the homepage immediately after seeing that warning, you should see the same (until Node.js releases that variable).

A few random notes:

  1. getServerSideProps is where it imitates fetching session data or account data or whatever (this part runs on the server). As an example, the logged-in session is simply a boolean true.

  2. It's probably a bad idea to use setSession(session) the way I do here. But since hooks are allowed only in FC's body, I have nowhere else to put it (ideally I'd set it from getServerSideProps).


Edit: Also, I didn't include useContext and createContext here, because I'm pretty sure I'm doing a hack and didn't want to complicate the example.

Edit 2: However, here's a Next.js example that uses context: https://github.com/vercel/next.js/tree/canary/examples/with-context-api

@bluematter
Copy link

I just ran into this issue and it was nasty for the UX.

A simple solution after a complex debugging session is to simply clear your store when the component unmounts. Let me know if this helps.

useEffect(() => {
    setSession({ id: "YOUR_SESSION" });

    return () => {
      setSession({});
    };
}, []);

If you wanted to cache your zustand store you could create a map with keys that represent the page id. This isn't ideal for sensitive data and it can hog memory if you're persisting a lot of data to it across your app but it's a strategic option if you want to load things faster.

@dai-shi dai-shi changed the title SSR data is shared SSR Example (SSR data is shared) Sep 13, 2020
@dai-shi
Copy link
Member

dai-shi commented Sep 14, 2020

Folks, I finally made an example on my own. It's based on https://github.com/vercel/next.js/tree/canary/examples/with-mobx (suggested in vercel/next.js#11222, @janek26 is still around?)

https://codesandbox.io/s/nextjs-with-zustand-x4z2g

Now, I understand what you mean by "SSR data is shared." It's not yet solved in the example currently.

@mkartchner994
Copy link

mkartchner994 commented Sep 21, 2020

I didn't test this too much and I don't know if this is a great way to do this but I believe this code sandbox (based on @dai-shi clock example) shows a way to solve the "SRR data is shared" problem. It uses React context and creates a Zustand store singleton in the React render vs importing and using the singleton directly. The other somewhat nice thing about this is the store singleton is set in a useRef so if the component that is setting the initalState on first render (_app.js in the example) happens to rerender for whatever reason you don't have to worry about it resetting the store values back to the initial state accidentally. I added a button in the _app.js file to illustrate that as well.

@dai-shi
Copy link
Member

dai-shi commented Sep 21, 2020

@mkartchner994 Hey, thanks for the example! I think it's valid to use context to avoid the global singleton. But, I'm not really sure if we should recommend this pattern, simply because the zustand api is not designed well for contexts. At the same time, I don't come up with better ideas. So, unless some next.js experts suggest something else, we'd have no other choices...

@callumbooth
Copy link

callumbooth commented Oct 13, 2020

I've created a PR in next.js to add a with-zustand example. It uses the information from this discussion using a context provider.

@dai-shi I think it should be fine to add the zustand store into context. I have done a little digging into react-redux and i believe it uses the same approach. The redux store is placed into context and then the useStore/useSelector hook access the context to get the redux store.

Would be great to get your feedback and I'll make any changes as necessary.

@dai-shi
Copy link
Member

dai-shi commented Oct 13, 2020

@callumbooth
Thanks for working on it!

As for adding zustand store (which is actually useStore hook) into context, it is OK if developers know what they are doing. I just want to note there's a pitfall in general for adding a hook into context (or props).

Let's see the general example.

const Ctx = createContext()

const Component = () => {
  const useStore = useContext(Ctx)
  const [count] = useStore() // this violates the rule of hooks
  return <div>{count}</div>
}

const App = () => {
  const [enabled, setEnabled] = useState(false)
  const useStore = () => useState(0)
  return (
    <div>
      <button onClick={() => setEnabled(x => !x)}>Toggle</button>
      <Ctx.Provider value={enabled ? useStore : () => [0] }>
        <Component />
      </Ctx.Provider>
    </div>
  )
}

@callumbooth
Copy link

I see what you're say. I'm not sure a way around it, hooks has many gotcha's like this. If the responsibility for creating the provider is moved into zustand does that reduce the chance that someone would do this?

I'm assuming redux would have the same problem as the one you're describing?

@dai-shi
Copy link
Member

dai-shi commented Oct 13, 2020

I'm assuming redux would have the same problem as the one you're describing?

No, react-redux doesn't have this issue, because it doesn't pass a hook in context. Hooks are imported directly.

If the responsibility for creating the provider is moved into zustand does that reduce the chance that someone would do this?

Yeah, that's possible. We don't want to change the core api, but middleware can.

@callumbooth
Copy link

callumbooth commented Oct 24, 2020

So I've been thinking about it and wanted to discuss it here before starting on any PR.

I agree I'm not a fan of putting a hook into the provider feels too easy to misuse, so I wondered if putting the vanilla store into the provider instead is the way to go.

As I understand it the src/index create function is an abstraction around the src/vanilla create function to ensure a store exists and then the logic for the useStore hook which closes over the store. What if we moved that logic so it can be shared between the src/index create function and an another hook that would abstract around getting the store from context?

Something like this:

//src/index.js

export function sharedUseStore(selector, equalityFn, api) {
  //...logic goes here
} 

export default function create(createState) {
  const api =
    typeof createState === 'function' ? createImpl(createState) : createState

  const useStore = (selector, equalityFn) => sharedUseStore(selector, equalityFn, api)

  //...rest of create function
}

export function useStoreFromProvider(selector, equalityFn) {
  const api = useContext(zustandContext)

  const values = sharedUseStore(selector, equalityFn, api)

  return values
}

This is just a rough idea and needs refining to avoid/reduce any impact to the core api.

Let me know your thoughts and if this heading towards the right direction or not.

@dai-shi
Copy link
Member

dai-shi commented Oct 25, 2020

We had some internal discussions before releasing v3 and one of my suggestions is to export two functions like createStore and useStore, which solves this context/props/component-lifecycle issue (in short, aligning rule of hooks.) This change wasn't accepted because, it's breaking from v2 and, for most of singleton use cases, the single exported function works better/simpler.


Now, if I understand it correctly, your suggestion is not a breaking change to the current v3 api, but adding a new function to export. Let's call it useStore instead of sharedUseStore for use* convention.

The way I understand is this:

// we can do it what we do currently.
import create from 'zustand'

const useStore = create(...)

const Component = () => {
  const values = useStore(selector)
// this is the new pattern
import create, { useStore } from 'zustand'

const store = create(...)

const Component = () => {
  const values = useStore(store, selector)

Hm, this can be done with middleware without changing the core like below:

import create from 'zustand'
import { useStore } from 'middleware'

I guess this is what I proposed in my previous comment.

Rereading your comment, I guess what you really mean is this.

import create from 'zustand/vanilla'
import { useStore } from 'zustand'

const store = create(...)

const Component = () => {
  const values = useStore(store, selector)

This actually works in v3, however we can't support this pattern in v4 (#160). That's why we are hesitant for this. (using vanilla in addition is not good for bundling either. zustand/vanilla is intended to be used without react.)


Are you on the same page? Happy to help if something is ambiguous. I might misunderstand your suggestion, so I'd wait for your response.

@callumbooth
Copy link

callumbooth commented Oct 25, 2020

Hmm, I was actually thinking that the exported useStore function would be so that it can be imported by the middleware but actually the middleware could use the create function as it accepts a vanilla store.

So the useStoreFromProvider hook could be

// middleware/index.js file

import create from 'zustand'

export function useStoreFromProvider(selector, equalityFn) {
  const api = useContext(zustandContext)

  const useStore = create(api)

  return useStore(selector, qualityFn)
 }

// someComponent.js

import {useStoreFromProvider} from 'middleware'

const Component = () => {
  const values = useStoreFromProvider(selector, eqFn)

I'm sticking with the provider as a requirement because with SSR (and nextjs) we need to create a new store every request so we need a way saving a reference to that new store, which allows any component within the tree access it. The provider would contain the vanilla store created by a factory function.

I'm probably missing something but I think this would still be compatible with v4.

@dai-shi
Copy link
Member

dai-shi commented Oct 25, 2020

@callumbooth Thanks for putting your brain to this issue.

const api = useContext(zustandContext)

This works in v3, but in v4 it has to be something like below:

const mutableSource = useContext(zustandContext)

(which is dependent on react, so can't be done in src/vanilla.ts.)


Does this work in practice? (apart from it being weird.)

// src/middleware.js

import create from 'zustand'

const ZustandContext = createContext()

export const ZustandProvider = ({ createState, children }) => {
  const store = create(createState)
  return (
    <ZustandContext.Provider value={{ store }}>
      {chidlren}
    </ZustandContext.Provider>
  )
}

export const useZustandStore = (selector, eqlFn) => {
  const store = useContext(ZustandContext)
  const useStore = store // this is the weird part
  return useStore(selector, eqlFn)
}

@callumbooth
Copy link

As this doesn’t change the core api, it should be fairly easy to get a poc created. I’ll try to get one created tomorrow.

Thanks for you help on this @dai-shi

@callumbooth
Copy link

Sorry taken me a little longer to get this created. Here is an initial poc which works when navigating between different pages but if you navigate to the same page, the page component seems to keep a reference to the old store so the page stops working. Any help would be appreciated to get this working https://codesandbox.io/s/vanilla-store-example-vim2c

@dai-shi
Copy link
Member

dai-shi commented Oct 29, 2020

Not sure if it's related to your current issue, but

export function useStore(selector, equalityFn) {
  const store = useContext(StoreContext);
  const useStore = create(store);

This won't work in the future. We need to create store just once, not per hook. (In other words, don't use zustand/vanilla for this use case.)

@callumbooth
Copy link

callumbooth commented Oct 29, 2020

Forgive me if I'm not understanding correctly but wont this line mean it isn't creating a new store just using the store we pass in? https://github.com/pmndrs/zustand/blob/master/src/index.ts#L31

const api: StoreApi<TState> = typeof createState === 'function' ? createImpl(createState) : createState

@Munawwar
Copy link
Contributor

Munawwar commented May 3, 2021

Here it is, zustand PR #375 + next.js:
https://codesandbox.io/s/nextjs-with-zustand-with-pr-375-736w3

You can test against @clbn's redux+next.js example, which is (hopefully) the equivalent of the example above:
https://codesandbox.io/s/03ibe

dai-shi added a commit that referenced this issue May 5, 2021
* context utils for SSR

* move useIsomorphicLayoutEffect to new file

* added build commands

* 1. remove the useEffect we dont need anymore 2. wrap context and hook into createZustand() function, but keep defaults

* issue #182 - changed name to createContext and useStore + added tests

* remove default Provider

* use alias to index file

* change 'createState' prop to 'initialStore'

that accepts useStore func/object from create().

This is needed as store access is needed for merging/memoizing, in next.js integration

* updated tests

* code review changes

* snapshot update

* add a section in readme

* chore imports and so on

Co-authored-by: daishi <daishi@axlight.com>
@Munawwar
Copy link
Contributor

Munawwar commented May 7, 2021

Next.js PR - vercel/next.js#24884
Once merged we can add it as an example in README.

@jagaapple
Copy link
Contributor

jagaapple commented May 7, 2021

@Munawwar @dai-shi We'd been waiting for Provider to set initial states for SSR hydration and Storybook, and thank you for your contributions.

However, I don't understand why Provider accepts Store instead of initial states. In our current project, we've wrapped Zustand using React.Context, and it's possible to give initial states generated in SSR to Provider so that we can use useStore anywhere like the following.

// store.ts
const { Provider, useStore } = ourWrappedCreateStore(...);
export { Provider, useStore };

// root.component.jsx
const RootComponent = (propsBySSR) => {
  return (
    // `propsBySSR` is generated in SSR
    <Provider initialState={propsBySSR.initialState}>
      <Main />
    </Provider>
  );
};

// root.stories.jsx
export default {
  title: "Root",
};

const Template = () => <Root />;

export const Default = Root.bind({});
Default.args = {
  // can set in Storybook
  initialState: { count: 999 },
};

// foo.component.jsx
const FooComponent = () => {
  // can import `useStore` from `store.ts`
  const count = useStore((state) => state.count);
  ...
};

In most cases, we want to create useStore globally and import in many components directly, but if useStore dependencies initial states, we cannot do.

Also, initialState of createContext in zustand/context is only for TypeScript, in fact the value isn't referred and evaluated.

Are our ideas correct? Are there any technical problems? Is this feature not for this case?

@dai-shi
Copy link
Member

dai-shi commented May 7, 2021

If I don't misunderstand, you can create it with initialState.

const RootComponent = (propsBySSR) => {
  return (
    <Provider initialStore={create(() => propsBySSR.initialState)}>
      <Main />
    </Provider>
  );
};

But, seems like there's misunderstanding. (Would be good to support lazy init, if this is a common pattern.)

@Munawwar
Copy link
Contributor

Munawwar commented May 7, 2021

However, I don't understand why Provider accepts Store instead of initial states.

Also, initialState of createContext in zustand/context is only for TypeScript, in fact the value isn't referred and evaluated.

@jagaapple I think that's your confusion - on createContext(). Right, it is optional parameter and not used internally except for types. So skip it entirely if you cannot pass it.

Though it is not just for TS, it also gives autocomplete/intellisense on editors even on JS codebases, if you pass a good state structure 😏. I have a JS project, and it was a good touch to have that autocomplete. But if it is causing more confusion than good, then we probably need to re-think it @dai-shi. Maybe solve it with some jsdoc? or rename parameter to "initialStateExample"?

@Munawwar
Copy link
Contributor

Munawwar commented May 7, 2021

Next.js PR - vercel/next.js#24884
Once merged we can add it as an example in README.

Merged (that was quick). https://github.com/vercel/next.js/tree/canary/examples/with-zustand. We can add this to README now.

@dai-shi
Copy link
Member

dai-shi commented May 7, 2021

Maybe solve it with some jsdoc?

jsdoc sounds good to start. not really sure how much it would mitigate. thoughts? @jagaapple
I had an idea to actually use initialState for default context value, but it's probably too confusing (and not use cases).

We can add this to README now.

Any suggestion? Maybe under ## React context section? Would you open a PR? @Munawwar

@Munawwar
Copy link
Contributor

Munawwar commented May 8, 2021

Ok, I slept over it.. my opinion changed.. let's remove it. Also because many SSR websites may have different initial states structure/type per page and JS folks probably wouldn't even bother using it. If they still want autocomplete they can use jsdoc type assertion on the exported useStore (/** @type {import('zustand/index').UseStore<MyState>} */)

@dai-shi
Copy link
Member

dai-shi commented May 8, 2021

@Munawwar I'm fine with removing it. Please open a PR.

@jagaapple
Copy link
Contributor

@Munawwar Sorry for being late. Yes, I agree that initialState argument is removed because we can give the state type as Generics in TypeScript instead of a value. Thank you for your contribution!

@jagaapple
Copy link
Contributor

Ahh, I understand the concept of the API. As @dai-shi wrote, putting initial states on the return value of create function makes us possible to give initial states from SSR and Storybook, and others.

However, I still don't understand the advantage of giving initialStore instead of initialState. For functions whose initial values are unlikely to be determined dynamically, we need to merge pure values and functions in create , which makes it less useful. If the Provider can accept initialState , we can pass only the value to be overwritten as well.

// -- Give Store instead of Initial States (Current)

// we have to define a function to create a store to merge initial states by SSR and others.
// also we need to call `createStore({})` when we want to use the store outside React Components.
const createStore = (initialState) => {
  return create((set) => ({
    count: 0,
    setCount: (value) => set({ count: value }),
    ...initialState,
  });
};
const { Provider, useStore } = createContext();

const RootComponent = (propsBySSR) => {
  // to improve performance, it's recommended to memoize creating a store.
  const initialStore = useMemo(() => createStore(propsBySSR), [propsBySSR]);

  return (
    <Provider initialStore={initialStore}>
      <Main />
    </Provider>
  );
};
// -- Give Initial States instead of Store

// we can create a store following README.
const store = create((set) => ({
  count: 0,
  setCount: (value) => set({ count: value }),
});
const { Provider, useStore } = createContext(store);

const RootComponent = (propsBySSR) => {
  // we don't need to memoize anything and just give initial states to `initialState` Prop of Provider.

  return (
    <Provider initialState={propsBySSR}>
      <Main />
    </Provider>
  );
};

The above code (giving initial states instead of a store) is just idea, and may have some technical problems in practice. I'm grateful that the Provider was provided in any design, and I just want to know why it was designed to pass a store. 🙂

@dai-shi
Copy link
Member

dai-shi commented May 8, 2021

@jagaapple I understand your proposition. I think it's a design choice.
Your version is handy for initialState, and the current version is more flexible, I guess.
(I agree we would need extra useMemo for the current version.)
I can think of one difference: The current version allows nested Providers.

<Provider initialStore={store1}>
  ...
  <Provider initialStore={store2}>

@jagaapple
Copy link
Contributor

jagaapple commented May 9, 2021

@dai-shi

I think it's a design choice.

I got it.

My idea can allow nested Providers as well because Provider and useStore are generated by Store.

const { Provider: Provider1, useStore: useStore1 } = createContext(store1);
const { Provider: Provider2, useStore: useStore2 } = createContext(store2);

<Provider1> // also can omit initialState in this design
  <Provider2 initialState={xxx}>
    ...
  </Provider2>
</Provider1>

@dai-shi
Copy link
Member

dai-shi commented May 9, 2021

My idea can allow nested Providers as well because Provider and useStore are generated by Store.

Yes, but it's different from single useStore with nested Providers for DI. I don't know the use case, but it's technically different.

@Munawwar
Copy link
Contributor

Munawwar commented May 9, 2021

We kept initialStore over initialState for a bit of flexibility. There is at least one use case for next.js where we needed to merge new states into an existing store before passing it into Provider. If Provider creates the store, then those cases cannot be achieved. (and we aren't ready to add some merging logic options in zustand itself, cause that probably would end-up opinionated/use-case specific)

@everdrone
Copy link

everdrone commented May 28, 2022

Found this issue after trying the example from the https://github.com/vercel/next.js repo

Is there a typed version of the same example with typescript? I'm having a hard time trying to figure it out

@dai-shi
Copy link
Member

dai-shi commented May 28, 2022

v4 has new APIs for context use case. It would be nice to update the example.
typing with middleware is improved in v4 too.

@jspizziri
Copy link

Hey all, I'm trying to implement the example SSR pattern in a new Next.js app. The problem I'm facing is that I read state outside of components frequently (ex. useMyStore.getState().foo, etc).

It doesn't seem like this is possible with the stores created for SSR. Is there a way to accomplish the above with SSR? Thanks in advance!

@dai-shi
Copy link
Member

dai-shi commented May 4, 2023

Should be possible if you expose the context or a custom hook that use the context:

const Component = () => {
  const store = useContext(zustandContext)
  const handleClick = () => {
    console.log('foo is: ', store.getState().foo)
  }
  // ...
}

@luixo
Copy link

luixo commented Jul 25, 2023

v4 has new APIs for context use case. It would be nice to update the example.
typing with middleware is improved in v4 too.

I made my own version of store creator to help me sync up query (an abstract initDataSource in this example, drop-in something like ParsedUrlQuery) and zustand stores.

⚠️ Caveats ⚠️

  • You have to patch-package zustand to make it export ExtractState and WithReact types (patch is in a spoiler below).
  • To recreate a proper useStore function after create with a store argument became deprecated I had to manually create a UseStoreCurried type and curry away a parameter with ParametersExceptFirst helper. I hope this will get fixed (though I'm not sure what's the proper way to do that)
zustand patch
diff --git a/node_modules/zustand/react.d.ts b/node_modules/zustand/react.d.ts
index 8646581..aedd8a3 100644
--- a/node_modules/zustand/react.d.ts
+++ b/node_modules/zustand/react.d.ts
@@ -1,9 +1,9 @@
 import type { Mutate, StateCreator, StoreApi, StoreMutatorIdentifier } from './vanilla';
-type ExtractState<S> = S extends {
+export type ExtractState<S> = S extends {
     getState: () => infer T;
 } ? T : never;
 type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'subscribe'>;
-type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {
+export type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {
     getServerState?: () => ExtractState<S>;
 };
 export declare function useStore<S extends WithReact<StoreApi<unknown>>>(api: S): ExtractState<S>;

The store creator helper:

// store.ts
import React from "react";

import {
  createStore as createZustandStore,
  useStore,
  StateCreator,
  StoreMutatorIdentifier,
  StoreApi,
  WithReact,
  ExtractState,
} from "zustand";

type ParametersExceptFirst<F> = F extends (arg0: any, ...rest: infer R) => any
  ? R
  : never;

// Type in whatever your init data source is
export type InitDataSource = unknown;

// curried useStore hook type recreation
type UseStoreCurried<T> = {
  <S extends WithReact<StoreApi<T>>>(): ExtractState<S>;
  <S extends WithReact<StoreApi<T>>, U>(
    selector: (state: ExtractState<S>) => U,
    equalityFn?: (a: U, b: U) => boolean,
  ): U;
  <TState, StateSlice>(
    selector: (state: TState) => StateSlice,
    equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
  ): StateSlice;
};

export const createStore = <
  T,
  Mos extends [StoreMutatorIdentifier, unknown][] = [],
>(
  stateCreator: StateCreator<T, [], Mos>,
  getInitData?: (initDataSource: InitDataSource) => Partial<T>,
) => {
  const StoreContext = React.createContext<StoreApi<T> | undefined>(undefined);
  const useStoreCurried: UseStoreCurried<T> = (...args: unknown[]) => {
    const store = React.useContext(StoreContext);
    if (!store) {
      throw new Error("Expected to have store context");
    }
    return useStore(store, ...(args as ParametersExceptFirst<typeof useStore>));
  };

  const Provider = React.memo<
    React.PropsWithChildren<{ initDataSource: InitDataSource }>
  >(({ children, initDataSource }) => {
    const [store] = React.useState(() => {
      const boundStore = createZustandStore<T, Mos>(stateCreator);
      if (getInitData) {
        boundStore.setState(getInitData(initDataSource));
      }
      return boundStore;
    });
    return React.createElement(
      StoreContext.Provider,
      { value: store },
      children,
    );
  });

  return { Provider, useStore: useStoreCurried };
};
A slightly shorter version to run the day we'll have getUseStore to curry the store
import React from "react";

import {
createStore as createZustandStore,
getUseStore,
UseStoreCurried,
StateCreator,
StoreMutatorIdentifier,
StoreApi,
} from "zustand";

// Type in whatever your init data source is
export type InitDataSource = unknown;

export const createStore = <
T,
Mos extends [StoreMutatorIdentifier, unknown][] = [],
>(
stateCreator: StateCreator<T, [], Mos>,
getInitData?: (initDataSource: InitDataSource) => Partial<T>,
) => {
const StoreContext = React.createContext<StoreApi<T> | undefined>(undefined);

const useStore: UseStoreCurried<T> = (...args) => {
  const store = React.useContext(StoreContext);
  if (!store) {
    throw new Error("Expected to have store context");
  }
  const useStoreCurried = React.useMemo(() => getUseStore(store), [store]);
  return useStoreCurried(...args);
};

const Provider = React.memo<
  React.PropsWithChildren<{ initDataSource: InitDataSource }>
>(({ children, initDataSource }) => {
  const [store] = React.useState(() => {
    const boundStore = createZustandStore<T, Mos>(stateCreator);
    if (getInitData) {
      boundStore.setState(getInitData(initDataSource));
    }
    return boundStore;
  });
  return React.createElement(
    StoreContext.Provider,
    { value: store },
    children,
  );
});

return { Provider, useStore };
};

You can use it to create a store like that:

// stores.ts
import { createStore } from "@/store";

type StoreType = {
  foo: number;
  setFoo: (nextFoo: number) => void;
};

export const myStore = createStore<StoreType>(
  (set) => ({
    foo: 0,
    setFoo: (nextFoo) => set({ foo: nextFoo }),
  }),
  (initData) => ({ foo: (initData as { foo: number })?.foo }),
);
// Hooks to use in a component
export const useFoo = () => myStore.useStore(({ foo }) => foo);
export const useSetFoo = () => myStore.useStore(({ setFoo }) => setFoo

Don't forget to wrap the component using hooks in a store provider!

import React from "react";

import { myStore } from "@/stores";
import { InitDataSource } from "@/store";

// You can add more stores here
const stores = [myStore];

export const StateProvider: React.FC<
  React.PropsWithChildren<{ initDataSource: InitDataSource }>
> = ({ initDataSource, children }) => (
  <>
    {stores.reduce(
      (acc, { Provider }) => (
        <Provider initDataSource={initDataSource}>{acc}</Provider>
      ),
      children,
    )}
  </>
);

Usage in a component:

import React from "react";

import { useFoo, useSetFoo } from "@/stores";

export const Component: React.FC = () => {
  const foo = useFoo();
  const setFoo = useSetFoo();
  const increment = React.useCallback(() => setFoo(foo + 1), [foo, setFoo]);
  return <div onClick={increment}>{foo}</div>;
};

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

Successfully merging a pull request may close this issue.