nuqs
nuqs copied to clipboard
Expo Router Adapter / CJS Support
I have stubbed out an adapter that would work for Expo Router:
import { Array, Option, pipe, Record } from 'effect'
import { router, type UnknownOutputParams, useGlobalSearchParams, useSegments } from 'expo-router'
import {
type unstable_AdapterInterface,
unstable_createAdapterProvider,
} from 'nuqs/dist/adapters/custom'
export function useGlobalQueryParams<TParams extends UnknownOutputParams = UnknownOutputParams>() {
const params = useGlobalSearchParams<TParams>()
const segments = useSegments()
const urlParams = segments
.filter((segment) => segment.startsWith('[') && segment.endsWith(']'))
.map((segment) => segment.slice(1, -1))
return Object.fromEntries(
Object.entries(params).filter(([key]) => !urlParams.includes(key)),
) as TParams
}
function useNuqsExpoRouterAdapter(): unstable_AdapterInterface {
const params = useGlobalQueryParams<Record<string, string>>()
const updateUrl = (searchParams: URLSearchParams) => {
const newParams = Object.entries(Object.fromEntries(searchParams))
const oldParams = pipe(params, Record.toEntries)
const paramsToRemove = pipe(
oldParams,
Array.filter(([oldKey]) =>
pipe(
newParams,
Array.findFirst(([newKey]) => oldKey === newKey),
Option.isNone,
),
),
Array.map(([key]) => [key, undefined]),
)
router.setParams({
...Object.fromEntries(paramsToRemove),
...Object.fromEntries(newParams),
})
}
return {
searchParams: new URLSearchParams(params),
updateUrl,
rateLimitFactor: 2,
getSearchParamsSnapshot: () => new URLSearchParams(params),
}
}
export const NuqsExpoAdapter = unstable_createAdapterProvider(useNuqsExpoRouterAdapter)
The problem I am running into is expo uses the metro bundler. Metro doesn't support ESM yet, so everything has to be CJS. Is there away we can add CJS support back to this package?
I did a quick and dirty cjs build of nuqs and it seems to work. I am gonna do some testing wiht Expo and wire everything up.
Thanks for the contribution!
Unfortunately, adding CJS support back (it was removed in nuqs v2) is not planned, it's time for the ecosystem to move forward.
What's blocking Metro from allowing ESM, do you know? It feels like efforts should be directed towards this goal instead of keeping CJS alive (a format deprecated since Node 18), that would allow modern libraries running in Expo/Metro.
Ya I'm fully with you. Metra has lagged behind for a long time. That's a beast to change and people are trying but you have to deal with Facebook. Do you mind if I publish a cjs fork for everyone using expo?
Let me poke at it a bit more this morning. I might be able to get around it. Will let you know.
Ok, did some bable config hacking. Got it working without cjs build. Running into issues on native now:
Basically location doesn't exist on native. Native needs to kinda run like server does. Gonna poke around and see what I can do.
Ok, seems like on dev you have some code that isn't released yet that will get around the above issue. Testing that now.
Ok here is how I have this working for me. I built the next branch. I have the following adapter:
import { Array, Option, pipe, Record } from 'effect'
import { router, type UnknownOutputParams, useGlobalSearchParams, useSegments } from 'expo-router'
import {
type unstable_AdapterInterface,
unstable_createAdapterProvider,
} from 'nuqs/dist/adapters/custom'
import { useCallback } from 'react'
export function useGlobalQueryParams<TParams extends UnknownOutputParams = UnknownOutputParams>() {
const params = useGlobalSearchParams<TParams>()
const segments = useSegments()
const urlParams = segments
.filter((segment) => segment.startsWith('[') && segment.endsWith(']'))
.map((segment) => segment.slice(1, -1))
return Object.fromEntries(
Object.entries(params).filter(([key]) => !urlParams.includes(key)),
) as TParams
}
function useNuqsExpoRouterAdapter(): unstable_AdapterInterface {
const params = useGlobalQueryParams<Record<string, string>>()
const updateUrl = useCallback(
(searchParams: URLSearchParams) => {
const newParams = Object.entries(Object.fromEntries(searchParams))
const oldParams = pipe(params, Record.toEntries)
const paramsToRemove = pipe(
oldParams,
Array.filter(([oldKey]) =>
pipe(
newParams,
Array.findFirst(([newKey]) => oldKey === newKey),
Option.isNone,
),
),
)
router.setParams({
...Object.fromEntries(paramsToRemove),
...Object.fromEntries(newParams),
})
},
[params],
)
return {
searchParams: new URLSearchParams(params),
updateUrl,
rateLimitFactor: 2,
getSearchParamsSnapshot: () => new URLSearchParams(params),
}
}
export const NuqsExpoAdapter = unstable_createAdapterProvider(useNuqsExpoRouterAdapter)
I followed the following post: https://github.com/expo/expo/issues/30323#issuecomment-2269881678 and add this to my babel.config.ts
overrides: [
{
test: [/nuqs/],
plugins: [
'babel-plugin-transform-import-meta',
'module:@reactioncommerce/babel-remove-es-create-require',
],
},
],
I then had to import from nuqs/dist/* to get imports to resolve. Works on both native and web for expo router.
@franky47 what the timeline for releasing support for getSearchParamsSnapshot?
I then had to import from nuqs/dist/* to get imports to resolve
Was that a TypeScript or a runtime issue? If runtime, I assume it's the Expo/Metro resolver that doesn't support package.json "exports" fields.
I have a few things I want to test on the React Router adapters before releasing 2.3.0 in GA (hopefully early next week when I'm back home from holidays), but there's [email protected] which you should be able to depend on right away.
Metro spits out the following:
WARN Attempted to import the module "/node_modules/nuqs/dist/adapters/custom" which is not listed in the "exports" of "/node_modules/nuqs" under the requested subpath "./dist/adapters/custom". Falling back to file-based resolution. Consider updating the call site or asking the package maintainer(s) to expose this API.
Hum that's weird, because we do define this export:
https://github.com/47ng/nuqs/blob/10e526de73d2a38966f66da12ddd74beff4f96fa/packages/nuqs/package.json#L103-L107
Do you get the same error when importing useQueryState (or anything else) from nuqs in app code?
Have to do this: import { useQueryState } from 'nuqs/dist'.
That breaks the babel hack to get around no cjs support.
I think if you got rid of ./dist and put everything at the root of the package, it would work.
Yeah I've been doing a lot of experiments to properly support various TypeScript configurations (see #708).
Putting everything at the top level might not be convenient in the monorepo setup, but it's worth a try.
Here's a preview deployment with everything at the top level:
pnpm add https://pkg.pr.new/nuqs@64e3ba98bfcbbdfcc9c49741d6d2e7fa5269d869
Seems like with that preview I run into the CJS issue again. I think importing from dist was letting me around it.
Ok, thanks for the feedback, it also gives me weird behaviours in some TS configs, so I'll close that branch.
Can we use this in Expo Router yet?
From Expo v53 onwards, Metro should be able to resolve ESM imports: https://docs.expo.dev/versions/v53.0.0/config/metro/#es-module-resolution
Thanks for the tip @hassankhan, from that doc:
Typically, a file that is imported with import from a Node module (rather than require), will use the ES Modules resolution strategy
and
By default, Metro will match different conditions depending on the platform and whether the resolution has started from a CommonJS require call, or an ES Modules import statement and will change the condition accordingly.
So if I read this correctly, for nuqs to work, the whole app would have to be ESM?
If someone has a template project that I could try (I've never played with Expo, and have very little experience with React Native), I could try and write a nuqs adapter for it.
Possibly relevant/useful PR: #996
I played a bit with Expo 53 and nuqs does load, however I'm reaching the limits of my understanding of it (and React Native I guess):
https://github.com/user-attachments/assets/6cb03bf7-26bd-4853-b97d-1a99e6e087a2
Hi @franky47, thanks for taking a look, really glad we can just pull in ESM directly 😀
With regards to global vs local search params, they do indeed behave a bit differently in Expo/React Native: https://docs.expo.dev/router/reference/url-parameters/#local-versus-global-url-parameters.
Really looking forward to this!
any guides on how can I use it with expo 53 ?
This is the experimental adapter code I used in the video:
// src/lib/nuqs-adapter.ts
import { useGlobalSearchParams, useRouter } from 'expo-router'
import {
unstable_createAdapterProvider,
unstable_UpdateUrlFunction,
type unstable_AdapterInterface,
} from 'nuqs/adapters/custom'
import { useCallback, useMemo } from 'react'
function useExpoRouterNuqsAdapter(): unstable_AdapterInterface {
const router = useRouter()
const global = useGlobalSearchParams()
const reactiveSearchParams = useMemo(() => {
const params = new URLSearchParams()
Object.entries(global).forEach(([key, value]) => {
if (value !== undefined) {
params.set(key, String(value))
}
})
return params
}, [JSON.stringify(global)])
const updateUrl = useCallback<unstable_UpdateUrlFunction>(
(search, options) => {
const query = Object.fromEntries(search.entries())
router.setParams(query)
},
[router]
)
return {
searchParams: reactiveSearchParams,
updateUrl,
getSearchParamsSnapshot() {
return reactiveSearchParams
},
rateLimitFactor: 1,
}
}
export const NuqsAdapter = unstable_createAdapterProvider(
useExpoRouterNuqsAdapter
)
Then wrap the RootLayout with that exported NuqsAdapter component.
Feedback welcome! 🙏
cc @evanbacon
@franky47 Using your given solution, I'm experiencing an issue.
When a search parameter is set back to its default value (specified by .withDefault), the parameter is not cleared from the URL.
I think it's because router.setParams just merges the params that are passed to the updateUrl function, and never actually clears previously set params.
@ReinhardtR give this a try, based on @izakfilmalter solution. I changed it to use useLocalSearchParams so other screens in the stack won't interfere. Params need to be explicitly set to undefined to be removed from the url for example when its a default value, I also had to filter "undefined" as a string when reading the url.
import { useCallback } from "react";
import {
router,
useLocalSearchParams,
useSegments,
type UnknownOutputParams,
} from "expo-router";
import {
unstable_createAdapterProvider,
type unstable_AdapterInterface,
} from "nuqs/adapters/custom";
export function useLocalQueryParams<
TParams extends UnknownOutputParams = UnknownOutputParams,
>() {
const params = useLocalSearchParams<TParams>();
const segments = useSegments();
const urlParams = segments
.filter((segment) => segment.startsWith("[") && segment.endsWith("]"))
.map((segment) => segment.slice(1, -1));
return Object.fromEntries(
Object.entries(params)
.filter(([key]) => !urlParams.includes(key))
.filter(([, value]) => value !== undefined && value !== "undefined"),
) as TParams;
}
function useNuqsExpoRouterAdapter(): unstable_AdapterInterface {
const params = useLocalQueryParams<Record<string, string>>();
const updateUrl = useCallback(
(searchParams: URLSearchParams) => {
const newParams = Object.fromEntries(searchParams);
const oldParams = params;
const paramsToRemoveKeys = Object.keys(oldParams).filter(
(oldKey) => !(oldKey in newParams),
);
const paramsToRemove = Object.fromEntries(
paramsToRemoveKeys.map((key) => [key, undefined]),
);
router.setParams({
...paramsToRemove,
...newParams,
});
},
[params],
);
return {
searchParams: new URLSearchParams(params),
updateUrl,
rateLimitFactor: 2,
getSearchParamsSnapshot: () => new URLSearchParams(params),
};
}
export const NuqsExpoAdapter = unstable_createAdapterProvider(
useNuqsExpoRouterAdapter,
);
@leosilberg i've been using this example (in expo 54) so far and haven't encountered any issues. are there any updates regarding this? @franky47 do you have any plans to develop an official adapter for expo router? we are using nuqs in our nextjs project and want to continue using the same pattern in our react native app. what would you recommend for expo users who want to use nuqs?