nuqs icon indicating copy to clipboard operation
nuqs copied to clipboard

Expo Router Adapter / CJS Support

Open izakfilmalter opened this issue 11 months ago • 26 comments

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?

izakfilmalter avatar Jan 01 '25 13:01 izakfilmalter

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.

izakfilmalter avatar Jan 01 '25 13:01 izakfilmalter

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.

franky47 avatar Jan 01 '25 13:01 franky47

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?

izakfilmalter avatar Jan 01 '25 13:01 izakfilmalter

Let me poke at it a bit more this morning. I might be able to get around it. Will let you know.

izakfilmalter avatar Jan 01 '25 13:01 izakfilmalter

Ok, did some bable config hacking. Got it working without cjs build. Running into issues on native now: image

Basically location doesn't exist on native. Native needs to kinda run like server does. Gonna poke around and see what I can do.

izakfilmalter avatar Jan 01 '25 14:01 izakfilmalter

Ok, seems like on dev you have some code that isn't released yet that will get around the above issue. Testing that now.

izakfilmalter avatar Jan 01 '25 15:01 izakfilmalter

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?

izakfilmalter avatar Jan 01 '25 15:01 izakfilmalter

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.

franky47 avatar Jan 01 '25 15:01 franky47

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.

izakfilmalter avatar Jan 01 '25 15:01 izakfilmalter

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?

franky47 avatar Jan 01 '25 15:01 franky47

Have to do this: import { useQueryState } from 'nuqs/dist'.

izakfilmalter avatar Jan 01 '25 15:01 izakfilmalter

Do you have unstable_enablePackageExports set ?

https://metrobundler.dev/docs/package-exports/

franky47 avatar Jan 01 '25 15:01 franky47

That breaks the babel hack to get around no cjs support.

izakfilmalter avatar Jan 01 '25 15:01 izakfilmalter

I think if you got rid of ./dist and put everything at the root of the package, it would work.

izakfilmalter avatar Jan 01 '25 15:01 izakfilmalter

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.

franky47 avatar Jan 01 '25 17:01 franky47

Here's a preview deployment with everything at the top level:

pnpm add https://pkg.pr.new/nuqs@64e3ba98bfcbbdfcc9c49741d6d2e7fa5269d869

franky47 avatar Jan 01 '25 19:01 franky47

Seems like with that preview I run into the CJS issue again. I think importing from dist was letting me around it.

izakfilmalter avatar Jan 01 '25 20:01 izakfilmalter

Ok, thanks for the feedback, it also gives me weird behaviours in some TS configs, so I'll close that branch.

franky47 avatar Jan 01 '25 20:01 franky47

Can we use this in Expo Router yet?

erickreutz avatar May 13 '25 15:05 erickreutz

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

hassankhan avatar Jun 12 '25 02:06 hassankhan

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

franky47 avatar Jun 12 '25 07:06 franky47

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

franky47 avatar Jun 12 '25 12:06 franky47

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.

hassankhan avatar Jun 12 '25 16:06 hassankhan

Really looking forward to this!

aretrace avatar Jun 13 '25 02:06 aretrace

any guides on how can I use it with expo 53 ?

Ali-Aref avatar Jun 28 '25 09:06 Ali-Aref

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! 🙏

franky47 avatar Jun 28 '25 13:06 franky47

cc @evanbacon

a-eid avatar Jul 02 '25 00:07 a-eid

@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 avatar Jul 28 '25 13:07 ReinhardtR

@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 avatar Jul 30 '25 16:07 leosilberg

@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?

ermeydan avatar Nov 29 '25 16:11 ermeydan