Skip to content

Instantly share code, notes, and snippets.

@jckw
Created July 21, 2023 17:52
Show Gist options
  • Save jckw/ec0a024907c255691f060a35cec7d2a5 to your computer and use it in GitHub Desktop.
Save jckw/ec0a024907c255691f060a35cec7d2a5 to your computer and use it in GitHub Desktop.
A simple and good auth context with an xstate state machine
import { useActor, useInterpret } from "@xstate/react"
import { assign, createMachine, InterpreterFrom } from "xstate"
import { createContext, useContext, useEffect, ReactNode } from "react"
import apiClient from "@/api/client"
import { useRouter } from "next/router"
import { useToast } from "@chakra-ui/react"
// Warning, if you are altering this machine, you will probably need to manually refresh
// the page. Because it is defined outside of React, it will not hot reload.
const authMachine = createMachine(
{
id: "auth",
initial: "checking",
context: {
user: null,
error: null,
},
states: {
checking: {
invoke: {
src: "checkAuth",
onDone: {
target: "authenticated",
actions: assign({ user: (context, event) => event.data }),
},
onError: "unauthenticated",
},
},
authenticated: {
on: {
LOGOUT: {
target: "unauthenticated",
actions: "logout",
},
},
},
unauthenticated: {
on: {
LOGIN: "loggingIn",
},
},
loggingIn: {
invoke: {
src: "login",
onDone: {
target: "authenticated",
actions: [
assign({ user: (_context, event) => event.data }),
assign({ error: () => null }),
],
},
onError: {
target: "unauthenticated",
actions: [
assign({ user: () => null }),
assign({ error: (_context, event) => event.data }),
],
},
},
},
},
},
{
services: {
checkAuth: async () => {
// Call API to check if user is authenticated or check JWT expiration etc.
try {
const { uuid } = await apiClient.auth.user.me.execute()
return uuid
} catch (e) {
throw new Error("User not authenticated")
}
},
login: async (_ctx, event) => {
if (event.type !== "LOGIN") throw new Error("Invalid event")
const { email, password } = event
try {
const { uuid, token } = await apiClient.auth.user.login.execute({
email,
password,
})
localStorage.setItem("@token", token!)
return uuid
} catch (e: unknown) {
throw new Error((e as any).body[0].message)
}
},
},
actions: {
logout: () => {
localStorage.removeItem("@token")
},
},
}
)
// Create a context for your service
const AuthContext = createContext<InterpreterFrom<typeof authMachine>>(
null as any
)
interface AuthProviderProps {
children: ReactNode
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const authService = useInterpret(authMachine)
const router = useRouter()
useEffect(() => {
const subscription = authService.subscribe((state) => {
console.log("Current state: ", state.value)
if (state.value === "unauthenticated" && router.pathname !== "/login") {
localStorage.removeItem("@token")
router.push("/login")
}
})
return subscription.unsubscribe
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authService])
return (
<AuthContext.Provider value={authService}>{children}</AuthContext.Provider>
)
}
export const useAuth = () => {
const authService = useContext(AuthContext)
const [state] = useActor(authService)
const toast = useToast()
if (authService === null) {
throw new Error("useAuth must be used within an AuthProvider")
}
useEffect(() => {
if (state.context.error) {
toast({
title: "Error",
description: (state.context.error as any).message,
status: "error",
duration: 5000,
isClosable: true,
})
}
}, [state.context.error, toast])
const login = (email: string, password: string) => {
return authService.send({ type: "LOGIN", email, password })
}
const logout = () => {
authService.send({ type: "LOGOUT" })
}
return { state, login, logout }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment