Grab the code in GitHub: https://github.com/pnavk/nextjs-rsc-modal-dialog-example
While working on an application using Next.js App Router and React Server Components, I needed to open a modal from two different places:
A button on the home page
A link on the navigation bar
With Next.js Pages router this would be trivial. The necessary state would need to be lifted up to the most common ancestor and then passed through as props. With RSC, this approach no longer works because the navigation bar is at the root layout of the application and the modal itself is much deeper in the tree. We can’t lift state up because it would end up turning all our components into client components.
We need a way to manipulate state in any child component without passing props 🤔
Note: I am using the Dialog component from the excellent shadcn/ui
component library.
One way to do this to is to pass state through the URL. Specifically, the modal component could use the presence of a specific query
parameter to show or hide itself. Let’s outline some goals for the implementation:
We must be able to toggle the visibility of the modal through the URL and without passing props.
We must preserve the native back-button behavior of the browser. Showing/hiding the modal should not alter this behavior.
We must preserve animations for showing and hiding the modal.
We must preserve the behavior of the
DialogTrigger
andDialogClose
components that come packaged with theDialog
component from theshadcn/ui
library.We must be able to share a link to the modal externally and the modal should render when users arrive to the site by clicking the link.
We must be able to easily toggle the modal open or closed from anywhere by simply using a link.
With our goals defined, let’s dive in.
Step 1: Define our Modal Component
We will begin by creating a Modal
component. This is a higher order component that can be reused across the entire application and encapsulates the behavior of showing or hiding the modal.
Let’s break down what is going on:
Our
Modal
component takes 2 props, arouteName
andchildren
. TherouteName
prop defines which query parameter to look for to determine if the modal should open or close.We declare a
useState
hook to manage the open state of the modal.We get all the query parameters in the current route through the
useSearchParams
hook innext/navigation
We set
const
isOpen = searchParams.has(routeName)
to determine if therouteName
is in the current list of query parameters or not.We define a
useEffect
hook which runs each time the value ofisOpen
changes and use the value ofisOpen
to set the state of the modal.Why a useEffect? If we don’t use an effect, when navigating to the route directly (via a sharable link) we get a hydration mismatch error. Using an effect ensures that the modal is only opened after the initial render.
We declare a function to listen to the Dialog component’s
onOpenChange
event. When theopen
state becomesfalse
, we replace the current route with the value returned from theusePathname
hook. This returns the current URL, without anyquery
parameters (e.g.localhost:3000/?hello
would return'localhost:3000/'
)When the open state becomes
true
, we append therouteName
as a query parameter to the current route. (e.g. isrouteName
is defined ashello
thenlocalhost:3000
would becomelocalhost:3000/?hello
)This ensures that clicking the
DialogTrigger
andDialogClose
buttons modify the current route as necessary, which triggers our effect, which shows or hides the modal.
Note: We use
router.replace
with a value ofscroll:false
- this is important:router.replace
preserves the native browser back button behavior because it doesn’t add the URL changes to the browser historyscroll:false
ensures that when the modal is opened or closed, the page doesn’t suddenly scroll to the top, which is the default behavior when navigating to a new route in Next.js.
Step 2: Create a dialog using the Modal component
With our Modal component ready, lets use it to create a dialog.
Let’s break down what is going on:
We first define a
RouteIdentifier
calledshow-info
that will be used to determine if the modal should open or close. If our URL containsshow-info
as a query parameter, that will trigger the modal to display.We use our
Modal
higher-order-component and pass if theRouteIdentifier
and the necessary children.
When we navigate to the url localhost:3000/?show-info
our modal dialog will appear. When show-info
is removed from the URL, the modal is closed.
Step 3: Open the dialog using a link
We want to be able to toggle the visibility of the dialog using a link. Lets create a component that we can reuse to help us do this throughout our app.
We will create another higher order component by wrapping the Link
component from next/link
Our ModalLink
component is pretty simple. It wraps the Link
component from next/link
and simply sets the values of replace
to true
and scroll
to false
.
This is the same thing we did in the Modal
component, when calling router.replace
. This ensures that the browser back button behavior and scroll position is preserved regardless of if the dialog is opened using the DialogTrigger
button or through a Link
.
Step 4: Putting it all together
Now its time to put it all together. Let’s setup a navigation bar that contains an Open Dialog
button:
Our NavigationBar
component is pretty simple. We use the ModalLink
component we created earlier and set the href
value to ?show-info
to indicate that when pressing this link, we want to add the show-info
query parameter to the current path.
Now lets create our root layout:
layout.tsx
Our root layout contains our navigation bar.
Lets create our page:
Our page simply contains the InfoModal
that we created earlier. This will render our DialogTrigger
button.
Demo:
demo