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,
userwasnulluntil 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
onAuthStateChangeevents, 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
useSupabaseAuthZustandSyncis called at the app root. - Ensure all components use Zustand for auth state.
- Ensure
- 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.