Skip to content

Instantly share code, notes, and snippets.

@nandorojo
Last active December 23, 2022 23:03
Show Gist options
  • Save nandorojo/052887f99bb61b54845474f324aa41cc to your computer and use it in GitHub Desktop.
Save nandorojo/052887f99bb61b54845474f324aa41cc to your computer and use it in GitHub Desktop.
Expo + Next.js Query Param state

Expo + Next.js Query Params State 🦦

A typical use-case on web for maintaining React State is your URL's query parameters. It lets users refresh pages & share links without losing their spot in your app.

URL-as-state is especially useful on Next.js, since next/router will re-render your page with shallow navigation.

This gist lets you leverage the power of URL-as-state, while providing a fallback to React state for usage in React Native apps.

It's essentially a replacement for useState.

First, create the schema for your query parameters:

type Query = {
  bookingId: string
  template: 'story' | 'square'
}

The values of Query must be primitives, since you can't use nested fields in a URL with next router.

Next, we're going to generate our useParam function:

type Query = {
  bookingId: string
  template: 'story' | 'square'
}

const { useParam } = createParams<Query>()

This usage of a factory is similar to react-navigation's createStackNavigator. It allows us to have great TypeScript safety.

Usage

Now that we've created our useParams function, call it in your component:

type Query = {
  bookingId: string
  template: 'story' | 'square'
}

const { useParam } = createParams<Query>()

export function App() {
  const [bookingId, setBookingId] = useParams('bookingId')
}

Whenever you call setBookingId, it will update the query parameter in the URL. To remove the query parameter, call setBookingId(null).

On native, this will function as normal React State.

Initial value

With React state, we pass an initial value like this:

const [selected, setSelected] = useState(true)

With useParam we achieve the same thing with the initial property:

const [template, setTemplate] = useParam('template', {
  initial: 'story'
})

However, on web, this might not aways be the initial value. This is because the initial value itself could be set from the URL on the first navigation.

initial gets used on web when these two cases are satisfied:

  1. the query param (in this case, template) is undefined
  2. you haven't called the set state function yet (in this case, setTemplate)

There is might appear to be an edge case here. What happens if you call setTemplate(null)? This will remove the query parameter from the URL, so we're left with an empty state. But it also won't fall back to the initial field, since this wouldn't match the React state behavior.

Can we find a way to provide a fallback value on web in this case, to make sure that our URL isn't the only source of truth?

The solution lies with the parse field.

Parsing values

One issue with having state in URLs is, users have an API to inject whatever state they want into your app.

This could break in many ways.

Take our Query type we wrote earlier:

type Query = {
  bookingId: string
  template: 'story' | 'square'
}

Our template is a required field that accepts square or story.

A naive approach would use it like this:

const [template, setTemplate] = useParam('template', {
  initial: 'story'
})

There are two problems here: what if the URL doesn't have template? Or worse, what if it does have template, but it doesn't match one of the types you specified?

Enter parse:

type Query = {
  bookingId: string
  template: 'story' | 'square'
}

const { useParam } = createParams<Query>()

const [template, setTemplate] = useParam('template', {
  initial: 'story',
  parse: (templateFromUrl) => {
    if (templateFromUrl === 'story' || templateFromUrl === 'square') {
      return templateFromUrl
    }
    return 'story'
  }
})

parse is the final piece of the puzzle. It lets you ensure that any state you're using from your URL is "safe".

It's also strictly typesafe, which is an added bonus.

The argument it receives will always be a string

parse gets run when this case is satisfied:

  1. the query param (in this case, template) is not undefined

Types

This hook has great strict types.

The state value it returns will always be State | undefined, unless you pass both an initial value and parse. That way, we know that on both web and native, we're always using values which match our state.

Stringify

It's possible you'll want to customize the way that the query param is stored in the URL.

If so, you can use the stringify property:

type Query = {
  bookingId: string
  template: 'story' | 'square'
}

const { useParam } = createParams<Query>()

const [bookingId, setBookingId] = useParam('bookingId', {
  stringify: (bookingId) => {
    // if we call setBookingId('123')
    // URL will be ?bookingId=artist-123
    return `artist-${bookingId}`
  },
  parse: (bookingIdFromUrl) => {
    return bookingIdFromUrl.replate('artist-', '')
  }
})
import { useCallback, useMemo, useRef, useState, useEffect } from 'react'
import { useRouting } from 'expo-next-react-navigation'
import { Platform } from 'react-native'
import Router from 'next/router'
function useStable<T>(value: T) {
const ref = useRef(value)
useEffect(function update() {
ref.current = value
}, [value])
return ref
}
type Config<
Required extends boolean,
ParsedType,
InitialValue
> = (Required extends false
? {
parse?: (value?: string) => ParsedType
}
: {
parse: (value?: string) => ParsedType
}) & {
stringify?: (value: ParsedType) => string
initial: InitialValue
}
type Params<
Props extends Record<string, unknown> = Record<string, string>,
Name extends keyof Props = keyof Props,
NullableUnparsedParsedType extends Props[Name] | undefined =
| Props[Name]
| undefined,
ParseFunction extends
| undefined
| ((value?: string) => NonNullable<NullableUnparsedParsedType>) = (
value?: string
) => NonNullable<NullableUnparsedParsedType>,
InitialValue = NullableUnparsedParsedType | undefined,
ParsedType = InitialValue extends undefined
? NullableUnparsedParsedType
: ParseFunction extends undefined
? NullableUnparsedParsedType
: NonNullable<NullableUnparsedParsedType>
> = NonNullable<ParsedType> extends string
? [name: Name, config: Config<false, ParsedType, InitialValue>] | [name: Name]
: [name: Name, config: Config<true, ParsedType, InitialValue>]
type Returns<
Props extends Record<string, unknown> = Record<string, string>,
Name extends keyof Props = keyof Props,
NullableUnparsedParsedType extends Props[Name] | undefined =
| Props[Name]
| undefined,
ParseFunction extends
| undefined
| ((value?: string) => NonNullable<NullableUnparsedParsedType>) = (
value?: string
) => NonNullable<NullableUnparsedParsedType>,
InitialValue = NullableUnparsedParsedType | undefined,
ParsedType = InitialValue extends undefined
? NullableUnparsedParsedType
: ParseFunction extends undefined
? NullableUnparsedParsedType
: NonNullable<NullableUnparsedParsedType>
> = readonly [
state: ParsedType | InitialValue,
setState: (value: ParsedType) => void
]
export function createParam<
Props extends Record<string, unknown> = Record<string, string>
>() {
function useParam<
Name extends keyof Props,
NullableUnparsedParsedType extends Props[Name] | undefined =
| Props[Name]
| undefined,
ParseFunction extends
| undefined
| ((value?: string) => NonNullable<NullableUnparsedParsedType>) = (
value?: string
) => NonNullable<NullableUnparsedParsedType>,
InitialValue = NullableUnparsedParsedType | undefined,
ParsedType = InitialValue extends undefined
? NullableUnparsedParsedType
: ParseFunction extends undefined
? NullableUnparsedParsedType
: NonNullable<NullableUnparsedParsedType>
>(
...[name, maybeConfig]: Params<
Props,
Name,
NullableUnparsedParsedType,
ParseFunction,
InitialValue,
ParsedType
>
): Returns<
Props,
Name,
NullableUnparsedParsedType,
ParseFunction,
InitialValue,
ParsedType
> {
const { parse, initial, stringify = (value: ParsedType) => `${value}` } =
maybeConfig || {}
const [nativeState, setNativeState] = useState<ParsedType | InitialValue>(
router?.getParam(name as string) ?? (initial as InitialValue)
)
const router = useRouting()
const stableStringify = useStable(stringify)
const stableParse = useStable(parse)
const initialValue = useRef(initial)
const hasSetState = useRef(false)
const setState = useCallback(
(value: ParsedType) => {
hasSetState.current = true
const { pathname, query } = Router
const newQuery = { ...query }
if (value != null && (value as any) !== '') {
newQuery[name as string] = stableStringify.current(value)
} else {
delete newQuery[name as string]
}
const willChangeExistingParam =
query[name as string] && newQuery[name as string]
const action = willChangeExistingParam ? Router.replace : Router.push
action(
{
pathname,
query: newQuery,
},
undefined,
{
shallow: true,
}
)
},
[name, stableStringify]
)
const webParam: string | undefined = router.getParam(name as string)
const state = useMemo<ParsedType>(() => {
let state: ParsedType
if (webParam === undefined && !hasSetState.current) {
state = initialValue.current as any
} else if (stableParse.current) {
state = stableParse.current?.(webParam)
} else {
state = webParam as any
}
return state
}, [stableParse, webParam])
if (Platform.OS !== 'web') {
return [nativeState, setNativeState]
}
return [state, setState]
}
return {
useParam,
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment