Next.js state management and dynamic routes

Hi. I'm working on a web app using Next.js. It's main purpose is to let me easily create and edit some entities related to a task. The entities live in a hierarchy. The actual entities don't matter, I'll use an IoT related example. Let's say we have Device -> Firmware version -> Available parameters as the hierarchy. Let's say the directory structure according to the hierarchy is as follows:
...
- layout.tsx
- navBar.tsx
- deviceList.tsx
- page.tsx
- device
- [deviceId]
- layout.tsx
- firmwareVersionList.tsx
- page.tsx
- firmware
- [firmwareId]
- layout.tsx
- parameterList.tsx
- page.tsx
...
...
...
...
- layout.tsx
- navBar.tsx
- deviceList.tsx
- page.tsx
- device
- [deviceId]
- layout.tsx
- firmwareVersionList.tsx
- page.tsx
- firmware
- [firmwareId]
- layout.tsx
- parameterList.tsx
- page.tsx
...
...
...
The index page would show the list of devices, while the layout provides the nav bar for the entire app. The page inside the [deviceId] folder shows the list of firmwares available for the selected device. The nav bar has search components to allow the user to quickly switch to any device / firmware / parameter. It looks something like the first screenshot. The search components become enabled as the relevant sub route is visited, so the search devices component is always enabled, the search firmware version component is enabled inside /device/[deviceId]/**, etc... Additionally the selected entity is shown on the search component (2nd screenshot). The appropriate entity is fetched in the corresponding layout that lives in the right sub route, for example the selected device is collected here:
...
- layout.tsx
- navBar.tsx
- deviceList.tsx
- page.tsx
- device
- [deviceId]
- layout.tsx <- Get device from db
- firmwareVersionList.tsx
- page.tsx
...
...
...
- layout.tsx
- navBar.tsx
- deviceList.tsx
- page.tsx
- device
- [deviceId]
- layout.tsx <- Get device from db
- firmwareVersionList.tsx
- page.tsx
...
...
Then currently the collected device object is shared with components in the sub routes after that via a context. My issue is with figuring out how to provide the selected device to the search device component in the nav bar.
4 Replies
Tamás Soós
Tamás Soós8mo ago
AFAIK we can't read the dynamic IDs in the layouts above a specific route. Right now there's a context provider wrapping the nav bar and a nested provider wrapping the relevant sub route that are kept somewhat in sync via useEffects. Here's the summary of what I'd like to achieve: - Share these entities globally so that all dependent components have easy access to them. - Initialise the entities in particular sub routes outside which they are undefined by default - The value the entities are getting initialised with should come from the server and be fetched in a server component - Components outside the sub route providing the entity should be able to access the global value as for example Device | undefined - Components inside the sub route providing the entity should be able to access the global value as simply Device - State updates should rerender dependent components everywhere in the tree.
Tamás Soós
Tamás Soós8mo ago
No description
No description
Tamás Soós
Tamás Soós8mo ago
I had a little more luck with Zustand. I could get rid of the nested contexts. I was able to access the entities as the required type (optional or not) in the right contexts via hooks. Here's my store:
import { Device } from '@prisma/client';
import { create } from 'zustand';

export interface AppState {
deviceId: number | null;
devices: Device[];
}

interface AppActions {
addDevice: (device: Device) => void;
setDevice: (device: Device) => void;
removeDevice: (deviceId: number) => void;
}

export const useAppStore = create<AppState & AppActions>()((set, get) => ({
deviceId: null,
devices: [],
addDevice: (device) =>
set((state) => ({ devices: [...state.devices, device] })),
setDevice: (device) =>
set((state) => ({
devices: state.devices.map((d) => (d.id === device.id ? device : d)),
})),
removeDevice: (deviceId) =>
set((state) => ({
devices: state.devices.filter((d) => d.id !== deviceId),
})),
}));

export function useDeviceOptional() {
return useAppStore((state) => {
// console.log(`Store: ${JSON.stringify(state)}`);
const deviceId = state.deviceId;
if (typeof deviceId === 'undefined') return undefined;

return state.devices.find((device) => device.id === deviceId);
});
}

export function useDevice() {
const device = useDeviceOptional();
if (typeof device === 'undefined')
throw new Error("Device hasn't been initialised!");

return device;
}

export type AppStore = ReturnType<typeof useAppStore>;
import { Device } from '@prisma/client';
import { create } from 'zustand';

export interface AppState {
deviceId: number | null;
devices: Device[];
}

interface AppActions {
addDevice: (device: Device) => void;
setDevice: (device: Device) => void;
removeDevice: (deviceId: number) => void;
}

export const useAppStore = create<AppState & AppActions>()((set, get) => ({
deviceId: null,
devices: [],
addDevice: (device) =>
set((state) => ({ devices: [...state.devices, device] })),
setDevice: (device) =>
set((state) => ({
devices: state.devices.map((d) => (d.id === device.id ? device : d)),
})),
removeDevice: (deviceId) =>
set((state) => ({
devices: state.devices.filter((d) => d.id !== deviceId),
})),
}));

export function useDeviceOptional() {
return useAppStore((state) => {
// console.log(`Store: ${JSON.stringify(state)}`);
const deviceId = state.deviceId;
if (typeof deviceId === 'undefined') return undefined;

return state.devices.find((device) => device.id === deviceId);
});
}

export function useDevice() {
const device = useDeviceOptional();
if (typeof device === 'undefined')
throw new Error("Device hasn't been initialised!");

return device;
}

export type AppStore = ReturnType<typeof useAppStore>;
The problem with this is the initialisation. I can't figure out how to properly initialise the global store according to the route. I made an attempt with a custom initialiser component:
'use client';

import { useEffect, useLayoutEffect } from 'react';
import { AppState, useAppStore } from './store';

export function AppStoreInitialiser({
children,
...props
}: React.PropsWithChildren<Partial<AppState>>) {
useEffect(() => {
console.log(`Setting device state: ${JSON.stringify(props)}`);
useAppStore.setState({ ...props });
console.log(useAppStore.getState());
}, []);

return children;
}
'use client';

import { useEffect, useLayoutEffect } from 'react';
import { AppState, useAppStore } from './store';

export function AppStoreInitialiser({
children,
...props
}: React.PropsWithChildren<Partial<AppState>>) {
useEffect(() => {
console.log(`Setting device state: ${JSON.stringify(props)}`);
useAppStore.setState({ ...props });
console.log(useAppStore.getState());
}, []);

return children;
}
I tried to wrap the sub routes with this in template.tsx files so that they set the state properly on every navigation, but useEffect runs first for the inner templates. So given this setup:
...
- layout.tsx
- template.tsx <- AppStoreInitialiser sets deviceId to null
- navBar.tsx
- deviceList.tsx
- page.tsx
- device
- [deviceId]
- layout.tsx <- Get device from db
- template.tsx <- AppStoreInitialiser sets deviceId to dynamic param
- firmwareVersionList.tsx
- page.tsx
...
...
...
- layout.tsx
- template.tsx <- AppStoreInitialiser sets deviceId to null
- navBar.tsx
- deviceList.tsx
- page.tsx
- device
- [deviceId]
- layout.tsx <- Get device from db
- template.tsx <- AppStoreInitialiser sets deviceId to dynamic param
- firmwareVersionList.tsx
- page.tsx
...
...
navigating to /device/2 first sets the store's deviceId to 2 then to null.
Tamás Soós
Tamás Soós8mo ago
Here's what I ended up with. The example is using slightly different entities.
GitHub
Fragmented initial state · pmndrs zustand · Discussion #2158
Hi. I'm trying to use a Zustand store in a Next.js app. Let's say the app state looks like this: interface Bear { id: number; name: string; } interface Cub { id: number; name: string; } int...