Skip to main content

Zustand + Supabase Auth State Management in React Native/Expo

Overview

This documentation explains how we manage authentication state in our Expo/React Native app using Supabase and Zustand. It covers the issues with traditional approaches, our solution, and provides code examples and best practices for developers.


Problem: Auth State Sync Issues in React Query + Supabase

Issues Observed

  • Delayed UI updates: After login/logout, the UI did not immediately reflect the new auth state.
  • First login returns null: On first login, user was null until a second load.
  • Logout not instant: Logging out did not update the UI until a full reload.
  • Inconsistent state between Metro and Xcode builds: The problem was more pronounced in production/native builds.

Root Causes

  • React Query is async and cache-based: It fetches session/user from Supabase, but only refetches on certain triggers (mount, focus, manual invalidation).
  • Supabase session is persisted in storage: On native, there can be a delay before tokens are loaded from storage.
  • No direct subscription to auth events: React Query does not listen to Supabase's onAuthStateChange events, so UI is not instantly updated.

Solution: Use Zustand for Global Auth State

Why Zustand?

  • Instant, global state updates across the app.
  • Directly subscribes to Supabase auth events for real-time sync.
  • Simple API and easy integration with React Context.

Implementation Steps

1. Create Zustand Store for Auth

import { create } from "zustand"
import { Session, User } from "@supabase/supabase-js"

interface AuthState {
session: Session | null
user: User | null
loading: boolean
setSession: (session: Session | null) => void
setUser: (user: User | null) => void
setLoading: (loading: boolean) => void
clear: () => void
}

export const useAuthStore = create<AuthState>((set) => ({
session: null,
user: null,
loading: true,
setSession: (session) => set({ session }),
setUser: (user) => set({ user }),
setLoading: (loading) => set({ loading }),
clear: () => set({ session: null, user: null, loading: false }),
}))

2. Sync Zustand Store with Supabase Auth Events

import React from "react"
import { supabase } from "@/lib/supabase"
import { useAuthStore } from "@/store/useAuthStore"

export function useSupabaseAuthZustandSync() {
const setSession = useAuthStore((s) => s.setSession)
const setUser = useAuthStore((s) => s.setUser)
const setLoading = useAuthStore((s) => s.setLoading)

React.useEffect(() => {
setLoading(true)
supabase.auth.getSession().then(({ data }) => {
setSession(data.session)
setLoading(false)
})
supabase.auth.getUser().then(({ data }) => {
setUser(data.user)
})
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
if (session?.user) {
setUser(session.user)
} else {
setUser(null)
}
})
return () => {
listener?.subscription.unsubscribe()
}
}, [setSession, setUser, setLoading])
}

3. Use Zustand State in AuthProvider

import { useSupabaseAuthZustandSync, useAuthStore } from "@/store/useAuthStore"

export function AuthProvider({ children }) {
useSupabaseAuthZustandSync()
const session = useAuthStore((s) => s.session)
const user = useAuthStore((s) => s.user)
const loading = useAuthStore((s) => s.loading)
// ...rest of context logic
}

4. Use Zustand State in Components

import { useAuthStore } from "@/store/useAuthStore"

function Profile() {
const user = useAuthStore((s) => s.user)
// ...
}

Example: Full AuthProvider with Zustand

import React, { createContext, useContext } from "react"
import { useSupabaseAuthZustandSync, useAuthStore } from "@/store/useAuthStore"
import { useAuthQuery } from "@/hooks/useAuthQuery"

const AuthContext = createContext(undefined)

export function AuthProvider({ children }) {
useSupabaseAuthZustandSync()
const session = useAuthStore((s) => s.session)
const user = useAuthStore((s) => s.user)
const loading = useAuthStore((s) => s.loading)
const auth = useAuthQuery() // for mutations

const value = {
session,
user,
loading,
isInitialized: !loading,
signUp: auth.signUp,
signIn: auth.signIn,
signOut: auth.signOut,
refreshSession: auth.refreshSession,
clearError: auth.clearError,
error: auth.signInError || auth.signUpError || auth.signOutError || undefined,
isAuthenticated: Boolean(session && user),
isUnauthenticated: Boolean(!session && !loading),
isSigningIn: auth.isSigningIn,
isSigningUp: auth.isSigningUp,
isSigningOut: auth.isSigningOut,
signInError: auth.signInError,
signUpError: auth.signUpError,
signOutError: auth.signOutError,
}

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

Best Practices & Tips

  • Always use Zustand for reading session/user state.
  • Use React Query only for mutations (signIn, signUp, signOut).
  • Call useSupabaseAuthZustandSync() once at the app root.
  • Do not use React Query for session/user queries anymore.
  • Handle errors via context for consistent UI.

Troubleshooting

  • UI not updating after login/logout:
    • Ensure useSupabaseAuthZustandSync is called at the app root.
    • Ensure all components use Zustand for auth state.
  • Session/user is null on first load:
    • Zustand will update as soon as Supabase loads tokens from storage.
  • React Query cache issues:
    • No longer relevant for session/user state.

Summary

This approach ensures instant, reliable, and global auth state updates in your Expo/React Native app, solving the common issues with async/cached state from React Query. Zustand + Supabase event subscription is the recommended pattern for robust auth state management.