Skip to content

Instantly share code, notes, and snippets.

@nandorojo
Last active February 17, 2025 08:31
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.

Revisions

  1. nandorojo revised this gist Dec 8, 2021. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion create-param.ts
    Original file line number Diff line number Diff line change
    @@ -110,7 +110,7 @@ export function createParam<
    const { parse, initial, stringify = (value: ParsedType) => `${value}` } =
    maybeConfig || {}
    const [nativeState, setNativeState] = useState<ParsedType | InitialValue>(
    (initial as InitialValue) ?? router?.getParam(name as string)
    router?.getParam(name as string) ?? (initial as InitialValue)
    )
    const router = useRouting()

  2. nandorojo revised this gist Nov 2, 2021. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion create-param.ts
    Original file line number Diff line number Diff line change
    @@ -110,7 +110,7 @@ export function createParam<
    const { parse, initial, stringify = (value: ParsedType) => `${value}` } =
    maybeConfig || {}
    const [nativeState, setNativeState] = useState<ParsedType | InitialValue>(
    initial as InitialValue
    (initial as InitialValue) ?? router?.getParam(name as string)
    )
    const router = useRouting()

  3. nandorojo revised this gist Nov 2, 2021. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion create-param.ts
    Original file line number Diff line number Diff line change
    @@ -125,7 +125,7 @@ export function createParam<
    hasSetState.current = true
    const { pathname, query } = Router
    const newQuery = { ...query }
    if (value != null) {
    if (value != null && (value as any) !== '') {
    newQuery[name as string] = stableStringify.current(value)
    } else {
    delete newQuery[name as string]
  4. nandorojo revised this gist Sep 9, 2021. 1 changed file with 8 additions and 3 deletions.
    11 changes: 8 additions & 3 deletions create-param.ts
    Original file line number Diff line number Diff line change
    @@ -123,15 +123,20 @@ export function createParam<
    const setState = useCallback(
    (value: ParsedType) => {
    hasSetState.current = true
    const { pathname, query, asPath } = Router
    const { pathname, query } = Router
    const newQuery = { ...query }
    if (value !== null) {
    if (value != null) {
    newQuery[name as string] = stableStringify.current(value)
    } else {
    delete newQuery[name as string]
    }

    Router.replace(
    const willChangeExistingParam =
    query[name as string] && newQuery[name as string]

    const action = willChangeExistingParam ? Router.replace : Router.push

    action(
    {
    pathname,
    query: newQuery,
  5. nandorojo revised this gist Sep 9, 2021. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions create-param.ts
    Original file line number Diff line number Diff line change
    @@ -160,10 +160,10 @@ export function createParam<
    }, [stableParse, webParam])

    if (Platform.OS !== 'web') {
    return [nativeState, setNativeState] as const
    return [nativeState, setNativeState]
    }

    return [state, setState] as const
    return [state, setState]
    }

    return {
  6. nandorojo revised this gist Sep 9, 2021. 1 changed file with 4 additions and 6 deletions.
    10 changes: 4 additions & 6 deletions create-param.ts
    Original file line number Diff line number Diff line change
    @@ -12,6 +12,7 @@ function useStable<T>(value: T) {
    return ref
    }


    type Config<
    Required extends boolean,
    ParsedType,
    @@ -30,9 +31,8 @@ type Config<
    type Params<
    Props extends Record<string, unknown> = Record<string, string>,
    Name extends keyof Props = keyof Props,
    NullableUnparsedParsedType extends Props[Name] | null | undefined =
    NullableUnparsedParsedType extends Props[Name] | undefined =
    | Props[Name]
    | null
    | undefined,
    ParseFunction extends
    | undefined
    @@ -52,9 +52,8 @@ type Params<
    type Returns<
    Props extends Record<string, unknown> = Record<string, string>,
    Name extends keyof Props = keyof Props,
    NullableUnparsedParsedType extends Props[Name] | null | undefined =
    NullableUnparsedParsedType extends Props[Name] | undefined =
    | Props[Name]
    | null
    | undefined,
    ParseFunction extends
    | undefined
    @@ -77,9 +76,8 @@ export function createParam<
    >() {
    function useParam<
    Name extends keyof Props,
    NullableUnparsedParsedType extends Props[Name] | null | undefined =
    NullableUnparsedParsedType extends Props[Name] | undefined =
    | Props[Name]
    | null
    | undefined,
    ParseFunction extends
    | undefined
  7. nandorojo revised this gist Sep 9, 2021. 1 changed file with 10 additions and 2 deletions.
    12 changes: 10 additions & 2 deletions create-param.ts
    Original file line number Diff line number Diff line change
    @@ -1,8 +1,16 @@
    import { useCallback, useMemo, useRef, useState } from 'react'
    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'
    import useStable from '@beatgig/design/hooks/use-stable'

    function useStable<T>(value: T) {
    const ref = useRef(value)
    useEffect(function update() {
    ref.current = value
    }, [value])

    return ref
    }

    type Config<
    Required extends boolean,
  8. nandorojo revised this gist Sep 9, 2021. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -136,7 +136,7 @@ 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 are satisfied:
    `parse` gets run when this case is satisfied:

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

  9. nandorojo revised this gist Sep 9, 2021. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -80,7 +80,7 @@ There is might appear to be an edge case here. What happens if you call `setTemp

    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 tbe `parse` field.
    The solution lies with the `parse` field.

    ### Parsing values

  10. nandorojo revised this gist Sep 9, 2021. 1 changed file with 3 additions and 1 deletion.
    4 changes: 3 additions & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -78,7 +78,9 @@ However, on web, this might not aways be the initial value. This is because the

    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.

    The solution is the `parse` field.
    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 tbe `parse` field.

    ### Parsing values

  11. nandorojo revised this gist Sep 9, 2021. 1 changed file with 4 additions and 0 deletions.
    4 changes: 4 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -76,6 +76,10 @@ However, on web, this might not aways be the initial value. This is because the
    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.

    The solution is 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.
  12. nandorojo revised this gist Sep 9, 2021. 1 changed file with 4 additions and 4 deletions.
    8 changes: 4 additions & 4 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -27,7 +27,7 @@ type Query = {
    template: 'story' | 'square'
    }

    const { useParams } = createParams<Query>()
    const { useParam } = createParams<Query>()
    ```

    This usage of a factory is similar to `react-navigation`'s `createStackNavigator`. It allows us to have great TypeScript safety.
    @@ -42,7 +42,7 @@ type Query = {
    template: 'story' | 'square'
    }

    const { useParams } = createParams<Query>()
    const { useParam } = createParams<Query>()

    export function App() {
    const [bookingId, setBookingId] = useParams('bookingId')
    @@ -111,7 +111,7 @@ type Query = {
    template: 'story' | 'square'
    }

    const { useParams } = createParams<Query>()
    const { useParam } = createParams<Query>()

    const [template, setTemplate] = useParam('template', {
    initial: 'story',
    @@ -152,7 +152,7 @@ type Query = {
    template: 'story' | 'square'
    }

    const { useParams } = createParams<Query>()
    const { useParam } = createParams<Query>()

    const [bookingId, setBookingId] = useParam('bookingId', {
    stringify: (bookingId) => {
  13. nandorojo revised this gist Sep 9, 2021. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -1,8 +1,8 @@
    # Expo + Next.js Query Params State 🦦

    A typical use-case on web for maintaining state is the URL's query parameters.
    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.
    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.

  14. nandorojo revised this gist Sep 9, 2021. 1 changed file with 2 additions and 0 deletions.
    2 changes: 2 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -1,3 +1,5 @@
    # Expo + Next.js Query Params State 🦦

    A typical use-case on web for maintaining state is the URL's query parameters.

    URL as state is especially useful on Next.js, since `next/router` will re-render your page with shallow navigation.
  15. nandorojo created this gist Sep 9, 2021.
    165 changes: 165 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,165 @@
    A typical use-case on web for maintaining state is the URL's query parameters.

    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:

    ```ts
    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:
    ```ts
    type Query = {
    bookingId: string
    template: 'story' | 'square'
    }

    const { useParams } = 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:

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

    const { useParams } = 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:

    ```ts
    const [selected, setSelected] = useState(true)
    ```

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

    ```ts
    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`)

    ### 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:

    ```ts
    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:
    ```ts
    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`:

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

    const { useParams } = 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 are 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:

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

    const { useParams } = 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-', '')
    }
    })
    ```
    166 changes: 166 additions & 0 deletions create-param.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,166 @@
    import { useCallback, useMemo, useRef, useState } from 'react'
    import { useRouting } from 'expo-next-react-navigation'
    import { Platform } from 'react-native'
    import Router from 'next/router'
    import useStable from '@beatgig/design/hooks/use-stable'

    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] | null | undefined =
    | Props[Name]
    | null
    | 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] | null | undefined =
    | Props[Name]
    | null
    | 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] | null | undefined =
    | Props[Name]
    | null
    | 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>(
    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, asPath } = Router
    const newQuery = { ...query }
    if (value !== null) {
    newQuery[name as string] = stableStringify.current(value)
    } else {
    delete newQuery[name as string]
    }

    Router.replace(
    {
    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] as const
    }

    return [state, setState] as const
    }

    return {
    useParam,
    }
    }