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 (w/ Next.js) #15

Closed
znk opened this issue Sep 9, 2020 · 37 comments · Fixed by #83
Closed

SSR example (w/ Next.js) #15

znk opened this issue Sep 9, 2020 · 37 comments · Fixed by #83

Comments

@znk
Copy link

znk commented Sep 9, 2020

It's a refreshing and beautiful take on state management, fitting perfectly my usecase!
Going through the source code, I don't see any re/hydration mechanism.

@dai-shi : If Jotai is SSR-ready or close to, could you set up an example? Or point out how?

Thanks!

@dai-shi
Copy link
Member

dai-shi commented Sep 9, 2020

@znk Thanks for opening up the issue. I'll be honest on this. It's not close at all. But, we would like to somehow support it, both for Jotai and Zustand pmndrs/zustand#182. My experience in SSR is limited. I'd need to learn technical details at first. Help appreciated. Thanks!

@lxsmnsyc
Copy link

lxsmnsyc commented Sep 11, 2020

@dai-shi React SSR doesn't support side-effects and error-boundaries. Suspense is similar to an Error Boundary that is solely tasked to catch Promises.

One of the problems that is blocking for SSR support is how useAtom supports normal and suspended states.
https://github.com/react-spring/jotai/blob/master/src/useAtom.ts#L59

In SSR patterns, usually you wouldn't throw a Promise immediately, as you would check first if window exists in the first place, directly rendering a fallback in place.

function SuspensefulComponent({ fallback }) {
  // Render fallback immediately for SSR
  if (typeof window === 'undefined') {
    return <>{ fallback }</>;
  }
  // Allow data-fetching for clients
  const data = loadSuspendedData();
  return <AsyncComponent data={data} />;
}

There are two solutions:

  • Like Recoil, you would have to separate suspended states from normal states, e.g. useSuspendedAtom.
  • You would require an optional fallback argument for useAtom which will render immediately for suspended states.

@dai-shi
Copy link
Member

dai-shi commented Sep 12, 2020

@lxsmnsyc Thanks for the notes! So, this is an issue with async behavior. Do you see any issues if we don't use async operations at all?

I'd expect React will eventually support Suspense in server. (Actually, I did once tried catching promise and retying render in server, in one of my experimental projects, which is totally a hack.)
Anyway, I see we need some additional hooks or options to support current React SSR.

We'd need to somehow support hydration. That's another issue, right?

@lxsmnsyc
Copy link

lxsmnsyc commented Sep 12, 2020

Do you see any issues if we don't use async operations at all?

There's not much of an issue. If you separate suspended states from normal states, developers would be aware about the existence of suspended data, giving them the responsibility to handle SSR. Normal states, on the other hand, wouldn't be an issue.

Consider this example:

const [user] = useAtom(userData);

There's not much to this but you wouldn't be aware if userData is an async atom or not. Developers might miss this most of the time, and some of them wouldn't be aware that if useData is an async atom, it would be suspending the component.

I'd expect React will eventually support Suspense in server.

I am not sure with the exact details but as I am aware, SSR skips side-effects. As for now, I only use the window check to prevent Suspense from happening during the SSR process.

We'd need to somehow support hydration. That's another issue, right?

I am still unsure how jotai maps states but generally initial states are recalled on the client-side, so it isn't much of an issue.

I think a good reference for this to consider is Vercel's SWR, as their API supports both effect-ful and suspended data fetching.

@stereoplegic
Copy link

Could initial (or persisted) state on SSR, followed by async on hydrate, resolve this?

@dai-shi
Copy link
Member

dai-shi commented Sep 15, 2020

Hello everyone! I finally made my first nextjs example with jotai, based on https://github.com/vercel/next.js/tree/canary/examples/with-mobx

https://codesandbox.io/s/nextjs-with-jotai-5ylrj

This has home-made hydration mechanism. It doesn't deal with async actions yet.

@dai-shi
Copy link
Member

dai-shi commented Sep 22, 2020

Hey! I made another example with async get.

https://codesandbox.io/s/nextjs-with-jotai-async-pm0l8?file=/store.js

I see at least two issues, which I hope someone can help.

  1. There's a warning in console Warning: Did not expect server HTML to contain a <div> in <div>.
  2. The very first ssr is rendered before prefetchedPostData is available.

@lxsmnsyc
Copy link

lxsmnsyc commented Sep 22, 2020

if (isSSR) {
  const id = INITIAL_POST_ID;
  // how can we guarantee the following is completed before ssr?
  (async () => {
    prefetchedPostData[id] = await fetchData(id);
  })();
}

There's no guarantee. If you want to prefetch data on server-side, you can guarantee the hydration by fetching the data through getServerSideProps, that is, getServerSideProps must wait for the initial data to resolve (you have to await), then <Index /> must receive the initialData supplied from the props. The issue with prefetchPostData is that the server and the client would have different instance of prefetchPostData and the two cannot share the instance.

export async function getServerSideProps() {
  return {
    props: {
       initialData: await fetchData(INITIAL_POST_ID), 
    },
  };
}

export default function IndexPage({ initialData }) {
  // should synchronously hydrate atom?
  return <Index initialData={initialData} />;
}

As for SSR Suspense, Next.js would respond with an internal error given that useAtom may throw a promise (SSR doesn't have error boundaries).

@dai-shi
Copy link
Member

dai-shi commented Sep 22, 2020

@lxsmnsyc Thanks for your help!
I updated my example: https://codesandbox.io/s/nextjs-with-jotai-async-pm0l8?file=/store.js

As for SSR Suspense, Next.js would respond with an internal error given that useAtom may throw a promise (SSR doesn't have error boundaries).

In my example, this is avoided with EMPTY_POST_DATA.


Since you hydrate the store in the App component, you could await for initialState by using the getInitialProps method for App

I'm not sure if I understand this one.

@lxsmnsyc
Copy link

lxsmnsyc commented Sep 22, 2020

@dai-shi So far, looks great. I'm sorry about the other comment, disregard what I said regarding the getInitialProps. I was actually viewing the index page and I didn't notice it, I thought the App component didn't catch the initialState props.

Also, I think an example using the context.query would look great for an actual hydration demo?:

export async function getServerSideProps(context) {
  return {
    props: {
       initialData: await fetchPostData(context.query.id || INITIAL_POST_ID), 
    },
  };
}

@dai-shi
Copy link
Member

dai-shi commented Sep 23, 2020

Also, I think an example using the context.query would look great for an actual hydration demo?

Thinking about this a bit, the server part shouldn't be difficult, but I'm not sure how to do it on client. As we keep the id in an atom state, we need to sync it somehow in the browser url. I expected shallow routing may help, but so far it doesn't work as expected.

@garretteklof
Copy link

I'm getting an Error: Rendered more hooks than during the previous render just by wrapping the <Provider> in _app.js. I originally had a whole architecture written but ran into this issue, then proceeded to comment everything out and it seems to be happening just by simply wrapping the root provider. Any thoughts on why this could be? I really want to make this library work as opposed to reaching for Recoil. I am a big fan of Zustand!

@dai-shi
Copy link
Member

dai-shi commented Oct 1, 2020

@garretteklof Hmm, not sure what causes the error. Would it be possible for you to create a small repro with codesandbox?

@garretteklof
Copy link

@dai-shi thanks for the quick reply! It looks like it's occurring with the official Next.js starter npx create-next-app -> here's a codesandbox with the template and the jotai <Provider> wrapped in _app.js

@dai-shi
Copy link
Member

dai-shi commented Oct 2, 2020

Thanks for the sandbox. And, I'm totally confused.

My example which worked fine no longer works after I modified package.json.
https://codesandbox.io/s/nextjs-with-jotai-5ylrj?file=/package.json

Now reverting the jotai version doesn't help. Or, I don't know how to revert.
Can anybody please help us?

@lxsmnsyc
Copy link

lxsmnsyc commented Oct 2, 2020

@dai-shi I would like to help but I can't build the dist files properly :/
image

@dai-shi
Copy link
Member

dai-shi commented Oct 2, 2020

I've never seen Provider_ts. Where do they come from???

@lxsmnsyc
Copy link

lxsmnsyc commented Oct 2, 2020

@dai-shi It seems that the .ts files aside from 'utils' are not being resolved by rollup and therefore does not get bundled. The published package seems fine: https://unpkg.com/jotai@0.7.4/index.cjs.js

What I did is just run yarn

@dai-shi
Copy link
Member

dai-shi commented Oct 2, 2020

...but the published package is the result of what I run yarn build.

@lxsmnsyc
Copy link

lxsmnsyc commented Oct 2, 2020

@dai-shi I tried re-cloning my fork repo, ran yarn and waited. The dist is still the same.

@dai-shi
Copy link
Member

dai-shi commented Oct 2, 2020

dai-shi/use-context-selector#24
Alright, so if we lock the version of use-context-selector v1.1.4 in codesandbox, it should work?

@dai-shi
Copy link
Member

dai-shi commented Oct 2, 2020

The dist is still the same.

That sounds interesting... There must be some differences between your env and my env.

@dai-shi
Copy link
Member

dai-shi commented Oct 2, 2020

https://codesandbox.io/s/nextjs-with-jotai-5ylrj
Finally, it's back. what a relief.

@lxsmnsyc Thanks again.

@garretteklof It should work if you re-install the package.

This was referenced Oct 2, 2020
@dai-shi
Copy link
Member

dai-shi commented Nov 8, 2020

Updated my example with jotai v0.10.4 which supports initialValues.
https://codesandbox.io/s/nextjs-with-jotai-5ylrj?file=/pages/_app.js

export default function App({ Component, pageProps }) {
  const { initialState } = pageProps;

  return (
    <Provider initialValues={initialState && [[clockAtom, initialState]]}>
      <Component {...pageProps} />
    </Provider>
  );
}

@dai-shi dai-shi reopened this Nov 8, 2020
@italodeandra
Copy link
Contributor

Hi @dai-shi,

Is the initialValues required or can I skip it if my SSR page doesn't require changing the atom's default values?

@dai-shi
Copy link
Member

dai-shi commented Nov 10, 2020

You can skip it. It's optional.

@garretteklof
Copy link

garretteklof commented Nov 24, 2020

Hi @dai-shi I'm currently using jotai in a Next.js application. I was running into an issue with version ^0.9.2 where an atom with an array value wasn't being properly updated between page changes (but would after a full refresh). Then, my application was being hydrated with a layoutEffect (previous documentation). I've since upgraded to the current version of jotai and am using the initialValues prop within the Provider. The issue that I'm running into is that the properties are not updating between page changes using next/router (but are after a full refresh). The function I have in initialValue is definitely being called (synchronous - and depends on initialState passed into Next.js getStaticProps) - and it's returning the right array - the state just isn't updating. Any quick thoughts as to why this would be?

@garretteklof
Copy link

Here's a quick codesandbox -> https://codesandbox.io/s/nextjs-with-jotai-forked-kxesh

It has to do with the nested initialState props .. for example if initialState has color property at the root-level it works fine (but doesn't when wrapped in art). Notice how a full refresh brings the correct state value.

If I'm doing something inanely silly (quite possible) - please forgive me. I've been cranking away on a deliverable and my mind is pretty 🌁

@dai-shi
Copy link
Member

dai-shi commented Nov 24, 2020

https://codesandbox.io/s/nextjs-with-jotai-forked-kxesh
@garretteklof Would you briefly explain what is weird and what is expected behavior?

@garretteklof
Copy link

State does not get updated in the new page without a refresh. The expected behavior would be the initialValues state to be updated via getStaticProps or getServerSideProps. Again, note if I do not nest the state it does in fact update. Or, if it's fully refreshed.

To mimic, make sure you start on index - refresh. Click 'static'. You should see pink. Refresh that page. You should then see yellow.

@garretteklof
Copy link

Admittedly, I'm very new to Next.js but it seems to me part of the 'magic' of Next and their router is that it acts as a client-side transition, but reruns their data fetching functions. My confusion is the correct data indeed reaching initialValues (you can throw in a console.log to see), but state not being updated. I could obviously treat it as a purely client side transition and update state manually on page change, but that would require to keep track of initial state in two places (client and SSR), which seems like an anti-Next pattern.

So maybe this is in fact what was intended here, but I don't think it's what was intended for Next.

@dai-shi
Copy link
Member

dai-shi commented Nov 25, 2020

@garretteklof Thanks for your explanation.
I now understand what you mean.
Yes, initialValues are initial values. When you see initialState with color yellow, the provider is already initialized and can't be changed afterwards.
There are two options: a) remount Provider, b) setAtom with props.
Assuming we want to keep atom values across pages, a) wouldn't be an option.

b) would be something like this.

export default function Static(pageProps) {            
  return (                                         
    <>                                                     
      <Index />                                                        
      <Color {...pageProps} />                                         
    </>                                                    
  );                                                       
}                                                          
const Color = ({ initialState }) => {              
  const [{ color }, setColor] = useAtom(colorAtom);        
  const art = initialState && initialState.art;                        
  useEffect(() => {                                                    
    if (art) {                                             
      setColor(art);                                       
    }                                                      
  }, [art]);                                               
  return <div>{color}</div>;                               
};                                              

I'm not very familiar with nextjs either, so it's not certain if this is idiomatic.

@garretteklof
Copy link

@dai-shi cool - thank you for your follow-up. It definitely makes sense that those would be truly 'initial' values, and I realized this was far more Next.js architecture than an implementation with jotai. What I ended up doing was a derivation of b) - a wrapper component that serves as the hydrator for page changes - so just tracking it on a more global level. Thanks again for your responsiveness and all your work on this library.

@srigi
Copy link

srigi commented Nov 25, 2020

@dai-shi I ended up doing was a derivation of b) - a wrapper component that serves as the hydrator for page changes

Could you share a code snippet of your solution?

@garretteklof
Copy link

Sure. For right now, I just have a function that I pass into the jotai Provider to dynamically build for SSR:

<Provider initialValues={init(initialState)}>

The Provider lives in _app.js and the init function is something to the effect of:

const init = (initialState = {}) => {
  let values = []
  const { user, map, settings } = initialState
  if (user) values.push([userAtom, user])
  if (map) values.push([mapAtom, { ...defaults.map, ...map }])
  if (settings) values.push([settingsAtom, { ...defaults.settings, ...settings }])
  return values
}

Currently I'm hosting all static state defaults in a json file (that's the defaults object spread you're seeing .. which is allowing initialState in Next.js to be a whitelist override)

Then for client-side transitioning (not SSR) there's another wrapper component which is essentially the useEffect hook that monitors for initialState changes:

const Hydrator = ({ initial = {}, children }) => {
  const [, setUser] = useImmerAtom(userAtom)
  const [, setMap] = useImmerAtom(mapAtom)
  const [, setSettings] = useImmerAtom(settingsAtom)
  const { user, map, settings } = initial
  useEffect(() => {
    if (user) setUser(() => user)
    if (map) setMap(() => map)
    if (settings) setSettings(() => settings)
  }, [user, map, settings, setUser, setMap, setSettings])
  return children
} 

(Note that these results don't currently match - I'm overriding all state here. Also it might be cleaner to create a single 'hydrate' atom that updates all relevant state with a write update, but I'm not sure how to achieve that same pattern with Immer)

And then in _app.js:

<Provider initialValues={init(initialState)}>
  <Hydrator initial={initialState}>
  ...other globals
  <Component {...pageProps} />
   ...other globals
  </Hydrator>
</Provider>

Another option as stated by @dai-shi is to move the jotai Provider out of _app.js and to the page-level (remounted on transitions) but this would assume no client state sharing between pages.

@dai-shi
Copy link
Member

dai-shi commented Jan 22, 2021

Closing this as resolved. Please open a new one if someone wants to raise a discussion.

@dai-shi dai-shi closed this as completed Jan 22, 2021
@sudomf
Copy link

sudomf commented Feb 17, 2022

Hey guys. A tricky workaround to make the initial state follow the changes. It's useful for NextJS getServerSideProps behavious for example.

HydratableJotaiProvider.tsx

import React, { useContext } from 'react';
import { Atom, SECRET_INTERNAL_getScopeContext } from 'jotai';
import { Scope } from 'jotai/core/atom';

const RESTORE_ATOMS = 'h';

export const HydratableJotaiProvider = ({
  initialValues,
  children
}: {
  initialValues: [Atom<unknown>, unknown][];
  children: React.ReactNode;
}) => {
  useEnhancedHydrateAtoms(initialValues);

  return <>{children}</>;
};

function useEnhancedHydrateAtoms(
  values: Iterable<readonly [Atom<unknown>, unknown]>,
  scope?: Scope
) {
  const ScopeContext = SECRET_INTERNAL_getScopeContext(scope);
  const scopeContainer = useContext(ScopeContext);
  const store = scopeContainer.s;

  if (values) {
    store[RESTORE_ATOMS](values);
  }
}

It works just like the useHydrateAtoms

<HydratableJotaiProvider initialValues={[[currentTabAtom, currentTab]]}>
  {children}
</HydratableStoreProvider>

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

Successfully merging a pull request may close this issue.

8 participants