Skip to main content

React client

WIP

<AuthProvider>, authUseSession, authUseSignIn, authUseSignOut, authUseBeginProvider. React 16.8+.

Provider

import { AuthProvider } from '@gentleduck/auth/client/react'

export function App({ children }: { children: React.ReactNode }) {
  return (
    <AuthProvider baseUrl="/auth">
      {children}
    </AuthProvider>
  )
}

Props:

PropPurpose
baseUrlDefault /auth.
clientPre-built vanilla client (advanced - for sharing across React + non-React).
noInitialFetchSkip the GET /auth/session call on mount. Useful for SSR where you already have the initial state.
initialStateHydrate from a server-rendered snapshot. Pair with noInitialFetch: true.
fetchOverride the fetch implementation.

authUseSession

import { authUseSession } from '@gentleduck/auth/client/react'

function Header() {
  const { data, status } = authUseSession()

  if (status === 'loading') return <Skeleton />
  if (status === 'guest') return <SignInButton />
  return <UserMenu identity={data.identity} />
}

Returns:

type UseSessionResult<Profile> =
  | { status: 'loading'; data: null }
  | { status: 'guest'; data: null }
  | { status: 'authed'; data: { session, identity: { profile: Profile, ... } } }

Use the discriminated union to drive your render branches - data is typed correctly in each arm.

Generic over Profile:

type Profile = { displayName: string; avatarUrl?: string }

const { data, status } = authUseSession<Profile>()
// data?.identity.profile.displayName  <- typed

authUseSignIn

import { authUseSignIn } from '@gentleduck/auth/client/react'

function SignInForm() {
  const signIn = authUseSignIn()

  return (
    <form
      onSubmit={async (event) => {
        event.preventDefault()
        const formData = new FormData(event.currentTarget)
        try {
          await signIn.mutateAsync({
            providerId: 'password',
            input: {
              email: formData.get('email') as string,
              password: formData.get('password') as string,
            },
          })
        } catch {
          // error is also on signIn.error
        }
      }}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit" disabled={signIn.isLoading}>
        {signIn.isLoading ? 'Signing in...' : 'Sign in'}
      </button>
      {signIn.error && <p>{signIn.error.code}</p>}
    </form>
  )
}

API:

FieldDescription
mutate(args)Fire-and-forget version. Errors go on signIn.error.
mutateAsync(args)Returns the result or throws.
isLoadingTrue while the request is in flight.
errorThe AuthError from the last failure (cleared on next call).
dataThe { session, identity } from the last success.
reset()Clear error and data.

authUseSignOut

import { authUseSignOut } from '@gentleduck/auth/client/react'

function SignOutButton() {
  const signOut = authUseSignOut()
  return (
    <button onClick={() => signOut.mutate()} disabled={signOut.isLoading}>
      Sign out
    </button>
  )
}

authUseBeginProvider

import { authUseBeginProvider } from '@gentleduck/auth/client/react'

function GoogleSignIn() {
  const begin = authUseBeginProvider()
  return (
    <button onClick={() => begin.mutate({ providerId: 'google', input: {} })}>
      Sign in with Google
    </button>
  )
}

function MagicLinkRequest() {
  const begin = authUseBeginProvider()
  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault()
        await begin.mutateAsync({
          providerId: 'magic-link',
          input: { email: e.currentTarget.email.value, channel: 'email' },
        })
      }}>
      <input name="email" type="email" required />
      <button>Email me a link</button>
    </form>
  )
}

SSR (Next.js)

app/providers.tsx
'use client'

import { AuthProvider } from '@gentleduck/auth/client/react'

export function Providers({
  children,
  initialState,
}: {
  children: React.ReactNode
  initialState: any
}) {
  return (
    <AuthProvider baseUrl="/api/auth" noInitialFetch initialState={initialState}>
      {children}
    </AuthProvider>
  )
}
app/layout.tsx
import { headers } from 'next/headers'
import { auth } from '~/lib/auth'
import { Providers } from './providers'

export default async function RootLayout({ children }) {
  const ctx = await auth.resolveSession({ headers: new Headers(headers()) })
  const initialState = ctx
    ? { session: ctx.session, identity: ctx.identity }
    : null

  return (
    <html>
      <body>
        <Providers initialState={initialState}>{children}</Providers>
      </body>
    </html>
  )
}

Now the first paint is already authenticated - no flash of guest UI.

Storybook decorator

import { authWithStorybook } from '@gentleduck/auth/client/react/storybook'

export const MyStory = {
  decorators: [authWithStorybook({ baseUrl: '/auth' })],
  render: () => <SignInButton />,
}

withAuth wraps the story in an <AuthProvider> backed by a mock client, so your auth-aware components render without a real server.

Errors

The error field on authUseSignIn / authUseSignOut / authUseBeginProvider is a typed AuthError. Branch on error.code:

{signIn.error?.code === 'AUTH/INVALID_CREDENTIALS' && <p>Wrong email or password</p>}
{signIn.error?.code === 'AUTH/MFA_REQUIRED' && <MfaForm />}
{signIn.error?.code === 'AUTH/RATE_LIMITED' && <p>Too many attempts; try again in a minute.</p>}