nuqs icon indicating copy to clipboard operation
nuqs copied to clipboard

useSearchParams() should be wrapped in a suspense boundary

Open frontendparham opened this issue 1 year ago • 3 comments

Context

What's your version of nuqs?

    "nuqs": "^1.16.1",

Next.js information (obtained by running next info):

Operating System:
  Platform: win32
  Arch: x64
  Version: Windows 10 Pro
Binaries:
  Node: 20.9.0
  npm: N/A
  Yarn: N/A
  pnpm: N/A
Relevant Packages:
  next: 14.1.0
  eslint-config-next: 14.0.4
  react: 18.2.0
  react-dom: 18.2.0
  typescript: 5.2.2
Next.js Config:
  output: standalone

Are you using:

  • ✅ The app router
  • ❌ The pages router
  • ❌ The basePath option in your Next.js config
  • ❌ The experimental windowHistorySupport flag in your Next.js config

Description

Hi, after updating nextjs to 14.1.0 and nuqs to 1.16.1 I get useSearchParams() should be wrapped in a suspense boundary error at multiple pages and I'm not using useSearchParams() directly in my code anywhere. So its comming from nuqs. also I read the nextjs documentation that says: Ensure that calls to useSearchParams() are wrapped in a Suspense boundary. But this can not be done with my code. should I disable it in next config or there is another way to fix this error.

frontendparham avatar Feb 11 '24 23:02 frontendparham

I encountered this issue as well when updating to Next.js 14.1.0 a couple of hours after its release, and forgot about it. Thanks for reminding me, this needs addressing in the docs.

The root of the problem is that Next.js 14.1.0 now requires all client components to be wrapped in a <Suspense> boundary. Using client-side hooks automatically opts the calling component as being a client component.

nuqs hooks use useSearchParams internally, and are bound to the same paradigm.

Folks are most likely to encounter this issue when using a top-level 'use client' directive in the page.tsx file and using client-side hooks in the default export (the Page component). It's a common pattern that now requires an intermediate step:

// page.tsx
'use client'

export default function Page() {
  const [foo, setFoo] = useQueryState('foo')
  // ...
}

Should now become:

// page.tsx
'use client'

export default function Page() {
  return (
    <Suspense>
      <Client />
    </Suspense>
  ) 
}

function Client() {
  const [foo, setFoo] = useQueryState('foo')
  // ...
}

This is not the only solution: the recommended way would be to have page.tsx be a server component (no 'use client' directive), and have it import a separate file (eg client.tsx) that uses client-side features, but the approach outlined above requires fewer changes. That being said, in the future Next.js may also decide that 'use client' is banned from top-level page.tsx files.

Another refactoring approach is to move the useQueryState(s) hooks as deep in the tree as possible. Since they are synced together, you can even split reads and writes, and locate them as close to their usage as needed:

// /app/foo/_components/searchBar.tsx
'use client'

export function SearchBar() {
  const [search, setSearch] = useQueryState('q', { defaultValue: '' })
  return <input value={search} onChange={e => setSearch(e.target.value)} />
}
// /app/foo/_components/searchResults.tsx
'use client'

export function SearchResults() {
  const [search] = useQueryState('q', { defaultValue: '' }) // read-only
  // ...
}

franky47 avatar Feb 12 '24 07:02 franky47

I wrap all client components in Suspense and then add them to page.jsx but I still get the error. So I had to disable it in next.config.js for now.

frontendparham avatar Feb 12 '24 13:02 frontendparham

In my case I wasn't using 'use client'; on any of my pages. I noticed pages that had 0 client side components such as my loading.tsx page were triggering this nextjs error.

The fix: I worked my way through the layout.tsx file, wrapping the top context provider in <suspense> and verified that the build passed, then slowly worked down the tree until I found the actual problem. The actual problem ended up being a search component (which used nuqs) in my <Header/> component.

tldr: I recommend starting in your layout.tsx and working down the tree.

ryparker avatar Mar 06 '24 18:03 ryparker

There are still a few days of Hacktoberfest left, so this issue could be a good occasion for someone to update the docs to point to the Next.js docs indicating why we need to wrap client components in <Suspense> (or rather why you get this error when using nuqs).

franky47 avatar Oct 25 '24 18:10 franky47

Hey would like to work on this can you brief me more about it?

HunainSiddiqui avatar Oct 30 '24 13:10 HunainSiddiqui

@HunainSiddiqui Sure, thanks!

The issue is that this error pops up if people use the nuqs hooks without having wrapped their client components in a Suspense boundary: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

Make yourself familiar with this issue thread, maybe play around with Next.js and nuqs hooks to trigger the error, and feel free to open a PR in the docs (the troubleshooting page would be a good place). No fully-generated ChatGPT/LLM content, please.

franky47 avatar Oct 30 '24 14:10 franky47

Have a look at this if any changes are required. If all is correct I will make a PR.

HunainSiddiqui avatar Oct 31 '24 06:10 HunainSiddiqui

:tada: This issue has been resolved in version 2.1.2 :tada:

The release is available on:

Your semantic-release bot :package::rocket:

github-actions[bot] avatar Nov 12 '24 13:11 github-actions[bot]

Getting this error on "nuqs": "^2.2.3".

Using suspense did solve my problem. But still, why wouldn't useQueryState just use the default value?

Doesn't make sense. Needing to wrap the whole thing in suspense adds a lot of layout shifts

Nusab19 avatar Dec 19 '24 02:12 Nusab19

@Nusab19 You would have this issue with useState too, it's not specific to nuqs, and there is nothing to fix on our side, it's a design choice by the React/Next.js teams.

useQueryState is a client-side hook, which means it's bundled into a .js script that needs to be sent to the client to be run there. This takes time (more so on slower/spotty network connections), and it's the reason why you would want to show a fallback UI using Suspense, to avoid blocking the rest of the page.

Needing to wrap the whole thing in suspense adds a lot of layout shifts

One way to avoid this is placing Suspense boundaries as low as possible in the React tree (minimising both the client bundle size and the layout shift sites), and designing fallback UIs that follow the same layout as your main UI. That last part might not always be possible on dynamic/user-generated data, I agree.

But still, why wouldn't useQueryState just use the default value?

It does read the value (whether from the URL or falling back to the defaultValue provided), once the client bundle has loaded. But the browser still needs that code to do so.

franky47 avatar Dec 19 '24 13:12 franky47

@franky47 Got it.

Though, just checked if useState throws the same error, it doesn't. :>

I understand your point on why it's happening. But is there a way to load only the nuqs bundle before the page becomes interactive? And nuqs is not that big in size. Is there any way to prefetch the nuqs bundle?

I used some skeleton's to minimize the layout shifts. So it's not that big of a deal now.

Another thing I forgot to say, I love nuqs tho. It has made my work much more easier. ( Apart from that Suspense thing :3 )

Nusab19 avatar Dec 19 '24 15:12 Nusab19

Though, just checked if useState throws the same error, it doesn't. :>

My bad, it's indeed a Next.js specific thing, coming from the internal use of useSearchParams.

Doing what you mentioned might be possible, but the amount of hoops you'd have to jump through to eject from the app router "way of doing things" is probably worth less than setting up proper Suspense boundaries (especially with upcoming features like PPR and dynamicIO / "use cache").

franky47 avatar Dec 20 '24 12:12 franky47

It doesn't seem right to always require a suspense boundary. It's forcing us to show loading spinners when a default value would be perfectly fine.

Here's the dumb solution that seems to work fine for me:

import {useQueryState, UseQueryStateOptions} from 'nuqs'

export const useQueryStateWithoutSuspense = ((key: string, options: UseQueryStateOptions<{}>) => {
  try {
    return useQueryState(key, options)
  } catch (err) {
    if (String(err).includes(`Bail out to client-side rendering: useSearchParams()`)) {
      return [null, () => {}] as never
    }
    throw err
  }
}) as typeof useQueryState

export default function MyPage() {
  const [query, setQuery] = useQueryStateWithoutSuspense('query')

  return <input value={query} onChange={ev => setQuery(ev.target.value)} />
}

I'm not sure if nextjs should fix this or nuqs should but it's silly.

mmkal avatar May 14 '25 18:05 mmkal