State Management Testing Suite in an SSR context

What we are testing
This project compares different state management solutions in Next.js 15, testing various approaches to data fetching and state management.
What we are not testing
We are NOT comparing SSR vs CSR. As we are using Next.js, everything is SSR.
We are solely focusing on the pros and cons of the different strategies that can be adopted for data fetching and state management in an SSR context.
We are testing both client state management and server state management because it's fun, but 99% of the time if your data lives on the server, you should very likely use a server state management solution.
State Managers
  • Vanilla React
  • Jotai
  • Zustand
  • React Query
  • SWR
Testing Approaches
  • Client based data fetching
  • RSC based data fetching
  • use hook based data fetching
Conclusion
Tanstack React Query seems to be the best solution.
By dehydrating queries, you can benefit from fast first load, even instant with Next.js prefetching.
On subsequent navigation, stale data is shown and refetch on mount without triggering suspense. You can probably show fresh data by implementing Tanstack Query prefetching.
Suspense boundaries can be placed around the client component, and you can generally limit the number of Hydration Boundaries needed because only queries are passed to the client.
You also benefit from all Tanstack Query native features like optimistic updates, dev tools, smart refetches, etc.
However it does still seems early, as there are some issues with the refetches sometimes not being triggered.
Notes
I observed some interesting behavior during this project. Notably that changing the value of prefetch in the Link component also change the behavior of the router cache. It seems that when prefetch is true, the router cache store s the dynamic route, but not when using prefetch null or false. (confirmed that when Next.js prefetch is true, the dynamic page is cached 5 minutes) As far as I know, this is not an issue, because the data is being managed from the client at this point, so you want to leverage your server state management system to prefetch the data, not Next.js.
Interstingly, for SWR, using prefetch false or null means that the dynamic page needs to be fully reloaded, but Tanstack Query someone manages to work well without. I'm thinking that the caching behavior in Tanstack query is smarter than in SWR, so the page is actually always regenerated when prefetch is false or null but Tanstack Query know that the data is available in the cache, and so doesn't suspend the component while SWR doesn't know that, and thinks it has to wait for the promise to resolve to actaully stop suspending.

This project is a work in progress. If you have any feedback, please let me know.

Data Fetching Techniques Comparison

TechniqueData FetchingProsCons
Client based
Data fetching starts on the client side after component mount
Triggered by useEffect hook, runs after initial render, client calls the API, which calls the database
  • Works with any state management solution
  • Declarative approach
  • If data is available show it, otherwise show loading state or error
  • Components are self-contained, no props needed
  • No hydration issues because servers renders the loading state
  • Data Fetching and Caching happen on the client
  • Easy to implement and extensive docs
  • Data fetching starts on the client
  • Need to pass by endpoints to get initial data
  • Risk of waterfalls with round-trips
  • User must be identified on each user-based request
  • No SEO benefits, html only has a loading shell
  • Requires a re-render after the mounting
RSC based
Data fetching is fully done in a parent wrapper RSC
Triggered after the http request, blocks the rendering of the RSCs until their data is fetched
  • SEO benefits thanks to incremental html generation
  • Database can be called directly, no need for additional endpoints
  • No rerender after mounting
  • All independant data fetches can start in parallel
  • Integrate well with Suspense to show instant loading state
  • Integrate with Error Boundaries for better error handling
  • Theoretical better TTI thanks to selective hydration
  • Less declarative approach (Suspense/Error Boundaries)
  • Need to pass props around to get the data
  • Or a provider needs to be initalized with each query
  • Caching is difficult to manage, esp. on navigation
  • Server doesn't know what is cached on client
  • Hard to integrate with state management
  • Hydration always has to be considered
  • Non-fetch must be explicitely specified to avoid SSG
  • CC wrapped in an RSC itself wrapped in a Suspense
  • RSC must be unique to the query they are doing
  • Poor documentation
use hook based
Data fetching starts in any parent RSC and is passed to the client
Triggered after the http request, blocks the rendering of the client components consuming the promise
  • Same benefits as with RSC
  • No need to wrap client components in RSC, use() can be used on client
  • Directly suspend the client component, not the RSC
  • Client decides to consume the promise or not
  • Route stays in router cache once promise is resolved
  • Harder to implement
  • Poor documentation

State Manager Comparison

In Next.js, navigation can always be instant using prefetching. However, we might not always want to prefetch and this navigation might show a loading.js, which is much less exciting than having the actual page with fresh data available. Below we explore how to instantly get the page we want, with stale or fresh data.

Doesn't work/Unknown
Doesn't seem to work or I don't know how to make it work
Never available
Fresh or Stale Data is never available
Prefetching
Fresh Data is available using Next.js prefetching
Router Cache
Stale Data is available without Next.js prefetching as long as route is cached
State ManagerData FetchingProsConsData Availability
Vanilla React
Client
  • Built into React
  • Easy to understand
  • No additional dependencies
  • Boilerplate
  • Manual cache management
  • No built-in loading/error states
  • Potential for race conditions
  • No automatic background updates
First Load: Data fetched on mount
Subsequent: via context + router cache
Vanilla React
Awaited in RSC
  • Simplest approach
  • Data is fixed at request time
  • Need to be passed through props
  • It's never enough
First Load: With Next.js prefetching only
Subsequent: With Next.js prefetching only
Vanilla React
Passed from RSC
  • Requires using context for DI = boilerplate/provider hell
  • No caching on the client side
First Load: With Next.js prefetching
Subsequent: Through router cache
Jotai
Client
  • Instant updates
  • Heavy boilerplate
  • No synching with server
  • No caching on the client side
  • Manual management of loading/error states
First Load: Data fetched on mount
Subsequent: Via atom + router cache
Jotai
Awaited in RSC
  • Decent syntax
  • Need a RSC wrapper for each atom
First Load: With Next.js prefetching only
Subsequent: With Next.js prefetching only
Jotai
Passed from RSC
  • Easy syntax
  • Hard to setup if even possible
  • Weird unwrapping of promise
First Load: Actions don't work
Subsequent: Actions don't work
Zustand
Client
  • Declarative
  • I love zustand syntax
  • Boilerplate (Provider via context)
  • No Prefetching
First Load: Data fetched on mount
Subsequent: Via store + router cache
Zustand
Awaited in RSC
  • Server Data has to be provided at once
  • No Granular Suspense
First Load: With Next.js prefetching
Subsequent: With Next.js prefetching
Zustand
Passed from RSC
  • Doesn't seem to work
  • No Documentation
First Load: Doesn't seem to work
Subsequent: Doesn't seem to work
React Query
Client
  • Declarative Approach
  • Simple mental model, all fetches happen on client
  • No boilerplate
  • Fetch where you need
  • Data fetching starts on the client
  • Need to pass by the server to get the data
  • Risk of waterfalls with round-trips
  • User must be identified on each request
  • No SEO benefits
  • Requires a re-render after the mounting
  • Data needs to be refetched on load
First Load: Data fetched on mount
Subsequent: via queryCache + router cache
React Query
Awaited in RSC
  • Fast first load
  • Less Waterfalls
  • SEO
  • DB can be called directly? (to confirm)
  • No rerender after mounting
  • Less declarative approach (Suspense and Error Boundaries)
  • Need to use a provider & RSC wrapper for each query ideally
  • Server doesn't know what is cached on client
  • Need for serialization/deserialization of the data
  • Non-fetch must be explicitely specified to avoid SSG
  • CC must be wrapped in an RSC itself wrapped in a Suspense
  • Need to create a wrapper for each query + wrapper function
First Load: With Next.js prefetching
Subsequent: With Next.js prefetching
React Query
Passed from RSC
  • All RSC benefits
  • queryCache decides what to use, only the query is passed from server
  • Directly suspends the client component, not the RSC
  • Multiple queries can be passed through a single HydrationBoundary
  • Perfect typescript with useSuspenseQuery hook
  • Less declarative approach (Suspense and Error Boundaries)
  • Need for serialization/deserialization of the data
  • Non-fetch must be explicitely specified to avoid SSG in Next.js
  • Require using useSuspenseQuery hook (tbh not a con)
  • Probably can't fetch directly from the db (to confirm)
  • Need to create a wrapper for each query + wrapper function
  • Refetches sometimes don't trigger
First Load: With Next.js prefetching
Subsequent: via queryCache + router cache
SWR
Client
  • Same than React Query
  • Same than React Query
  • It's not React Query
First Load: Data fetched on mount
Subsequent: via SWR cache + router cache
SWR
Awaited in RSC
  • Same than React Query
  • Much simpler approach than React Query
  • No need for a special hook
  • Same than React Query
  • It's not React Query
First Load: With Next.js prefetching only
Subsequent: With Next.js prefetching only
SWR
Passed from RSC
  • Same than React Query
  • Much simpler approach than React Query
  • No need for a special hook
  • Typescript thinks data is undefined
  • Need to be revalidated on mount to be added to the cache
  • It's not React Query
First Load: With Next.js prefetching only
Subsequent: Once put in the cache, it's instant