--- name: fp-ts-react description: Practical patterns for using fp-ts with React - hooks, state, forms, data fetching. Use when building React apps with functional programming patterns. Works with React 18/19, Next.js 14/15. risk: safe source: https://github.com/whatiskadudoing/fp-ts-skills --- # Functional Programming in React Practical patterns for React apps. No jargon, just code that works. ## When to Use This Skill - When building React apps with fp-ts for type-safe state management - When handling loading/error/success states in data fetching - When implementing form validation with error accumulation - When using React 18/19 or Next.js 14/15 with functional patterns --- ## Quick Reference | Pattern | Use When | |---------|----------| | `Option` | Value might be missing (user not loaded yet) | | `Either` | Operation might fail (form validation) | | `TaskEither` | Async operation might fail (API calls) | | `RemoteData` | Need to show loading/error/success states | | `pipe` | Chaining multiple transformations | --- ## 1. State with Option (Maybe It's There, Maybe Not) Use `Option` instead of `null | undefined` for clearer intent. ### Basic Pattern ```typescript import { useState } from 'react' import * as O from 'fp-ts/Option' import { pipe } from 'fp-ts/function' interface User { id: string name: string email: string } function UserProfile() { // Option says "this might not exist yet" const [user, setUser] = useState>(O.none) const handleLogin = (userData: User) => { setUser(O.some(userData)) } const handleLogout = () => { setUser(O.none) } return pipe( user, O.match( // When there's no user () => , // When there's a user (u) => (

Welcome, {u.name}!

) ) ) } ``` ### Chaining Optional Values ```typescript import * as O from 'fp-ts/Option' import { pipe } from 'fp-ts/function' interface Profile { user: O.Option<{ name: string settings: O.Option<{ theme: string }> }> } function getTheme(profile: Profile): string { return pipe( profile.user, O.flatMap(u => u.settings), O.map(s => s.theme), O.getOrElse(() => 'light') // default ) } ``` --- ## 2. Form Validation with Either Either is perfect for validation: `Left` = errors, `Right` = valid data. ### Simple Form Validation ```typescript import * as E from 'fp-ts/Either' import * as A from 'fp-ts/Array' import { pipe } from 'fp-ts/function' // Validation functions return Either const validateEmail = (email: string): E.Either => email.includes('@') ? E.right(email) : E.left('Invalid email address') const validatePassword = (password: string): E.Either => password.length >= 8 ? E.right(password) : E.left('Password must be at least 8 characters') const validateName = (name: string): E.Either => name.trim().length > 0 ? E.right(name.trim()) : E.left('Name is required') ``` ### Collecting All Errors (Not Just First One) ```typescript import * as E from 'fp-ts/Either' import { sequenceS } from 'fp-ts/Apply' import { getSemigroup } from 'fp-ts/NonEmptyArray' import { pipe } from 'fp-ts/function' // This collects ALL errors, not just the first one const validateAll = sequenceS(E.getApplicativeValidation(getSemigroup())) interface SignupForm { name: string email: string password: string } interface ValidatedForm { name: string email: string password: string } function validateForm(form: SignupForm): E.Either { return pipe( validateAll({ name: pipe(validateName(form.name), E.mapLeft(e => [e])), email: pipe(validateEmail(form.email), E.mapLeft(e => [e])), password: pipe(validatePassword(form.password), E.mapLeft(e => [e])), }) ) } // Usage in component function SignupForm() { const [form, setForm] = useState({ name: '', email: '', password: '' }) const [errors, setErrors] = useState([]) const handleSubmit = () => { pipe( validateForm(form), E.match( (errs) => setErrors(errs), // Show all errors (valid) => { setErrors([]) submitToServer(valid) // Submit valid data } ) ) } return (
{ e.preventDefault(); handleSubmit() }}> setForm(f => ({ ...f, name: e.target.value }))} placeholder="Name" /> setForm(f => ({ ...f, email: e.target.value }))} placeholder="Email" /> setForm(f => ({ ...f, password: e.target.value }))} placeholder="Password" /> {errors.length > 0 && (
    {errors.map((err, i) =>
  • {err}
  • )}
)}
) } ``` ### Field-Level Errors (Better UX) ```typescript type FieldErrors = Partial> function validateFormWithFieldErrors(form: SignupForm): E.Either { const errors: FieldErrors = {} pipe(validateName(form.name), E.mapLeft(e => { errors.name = e })) pipe(validateEmail(form.email), E.mapLeft(e => { errors.email = e })) pipe(validatePassword(form.password), E.mapLeft(e => { errors.password = e })) return Object.keys(errors).length > 0 ? E.left(errors) : E.right({ name: form.name.trim(), email: form.email, password: form.password }) } // In component {errors.email && {errors.email}} ``` --- ## 3. Data Fetching with TaskEither TaskEither = async operation that might fail. Perfect for API calls. ### Basic Fetch Hook ```typescript import { useState, useEffect } from 'react' import * as TE from 'fp-ts/TaskEither' import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function' // Wrap fetch in TaskEither const fetchJson = (url: string): TE.TaskEither => TE.tryCatch( async () => { const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() }, (err) => err instanceof Error ? err : new Error(String(err)) ) // Custom hook function useFetch(url: string) { const [data, setData] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { setLoading(true) setError(null) pipe( fetchJson(url), TE.match( (err) => { setError(err) setLoading(false) }, (result) => { setData(result) setLoading(false) } ) )() }, [url]) return { data, error, loading } } // Usage function UserList() { const { data, error, loading } = useFetch('/api/users') if (loading) return
Loading...
if (error) return
Error: {error.message}
return (
    {data?.map(user =>
  • {user.name}
  • )}
) } ``` ### Chaining API Calls ```typescript // Fetch user, then fetch their posts const fetchUserWithPosts = (userId: string) => pipe( fetchJson(`/api/users/${userId}`), TE.flatMap(user => pipe( fetchJson(`/api/users/${userId}/posts`), TE.map(posts => ({ ...user, posts })) )) ) ``` ### Parallel API Calls ```typescript import { sequenceT } from 'fp-ts/Apply' // Fetch multiple things at once const fetchDashboardData = () => pipe( sequenceT(TE.ApplyPar)( fetchJson('/api/user'), fetchJson('/api/stats'), fetchJson('/api/notifications') ), TE.map(([user, stats, notifications]) => ({ user, stats, notifications })) ) ``` --- ## 4. RemoteData Pattern (The Right Way to Handle Async State) Stop using `{ data, loading, error }` booleans. Use a proper state machine. ### The Pattern ```typescript // RemoteData has exactly 4 states - no impossible combinations type RemoteData = | { _tag: 'NotAsked' } // Haven't started yet | { _tag: 'Loading' } // In progress | { _tag: 'Failure'; error: E } // Failed | { _tag: 'Success'; data: A } // Got it! // Constructors const notAsked = (): RemoteData => ({ _tag: 'NotAsked' }) const loading = (): RemoteData => ({ _tag: 'Loading' }) const failure = (error: E): RemoteData => ({ _tag: 'Failure', error }) const success = (data: A): RemoteData => ({ _tag: 'Success', data }) // Pattern match all states function fold( rd: RemoteData, onNotAsked: () => R, onLoading: () => R, onFailure: (e: E) => R, onSuccess: (a: A) => R ): R { switch (rd._tag) { case 'NotAsked': return onNotAsked() case 'Loading': return onLoading() case 'Failure': return onFailure(rd.error) case 'Success': return onSuccess(rd.data) } } ``` ### Hook with RemoteData ```typescript function useRemoteData(fetchFn: () => Promise) { const [state, setState] = useState>(notAsked()) const execute = async () => { setState(loading()) try { const data = await fetchFn() setState(success(data)) } catch (err) { setState(failure(err instanceof Error ? err : new Error(String(err)))) } } return { state, execute } } // Usage function UserProfile({ userId }: { userId: string }) { const { state, execute } = useRemoteData(() => fetch(`/api/users/${userId}`).then(r => r.json()) ) useEffect(() => { execute() }, [userId]) return fold( state, () => , () => , (err) => , (user) => ) } ``` ### Why RemoteData Beats Booleans ```typescript // ❌ BAD: Impossible states are possible interface BadState { data: User | null loading: boolean error: Error | null } // Can have: { data: user, loading: true, error: someError } - what does that mean?! // ✅ GOOD: Only valid states exist type GoodState = RemoteData // Can only be: NotAsked | Loading | Failure | Success ``` --- ## 5. Referential Stability (Preventing Re-renders) fp-ts values like `O.some(1)` create new objects each render. React sees them as "changed". ### The Problem ```typescript // ❌ BAD: Creates new Option every render function BadComponent() { const [value, setValue] = useState(O.some(1)) useEffect(() => { // This runs EVERY render because O.some(1) !== O.some(1) console.log('value changed') }, [value]) } ``` ### Solution 1: useMemo ```typescript // ✅ GOOD: Memoize Option creation function GoodComponent() { const [rawValue, setRawValue] = useState(1) const value = useMemo( () => O.fromNullable(rawValue), [rawValue] // Only recreate when rawValue changes ) useEffect(() => { // Now this only runs when rawValue actually changes console.log('value changed') }, [rawValue]) // Depend on raw value, not Option } ``` ### Solution 2: fp-ts-react-stable-hooks ```bash npm install fp-ts-react-stable-hooks ``` ```typescript import { useStableO, useStableEffect } from 'fp-ts-react-stable-hooks' import * as O from 'fp-ts/Option' import * as Eq from 'fp-ts/Eq' function StableComponent() { // Uses fp-ts equality instead of reference equality const [value, setValue] = useStableO(O.some(1)) // Effect that understands Option equality useStableEffect( () => { console.log('value changed') }, [value], Eq.tuple(O.getEq(Eq.eqNumber)) // Custom equality ) } ``` --- ## 6. Dependency Injection with Context Use ReaderTaskEither for testable components with injected dependencies. ### Setup Dependencies ```typescript import * as RTE from 'fp-ts/ReaderTaskEither' import { pipe } from 'fp-ts/function' import { createContext, useContext, ReactNode } from 'react' // Define what services your app needs interface AppDependencies { api: { getUser: (id: string) => Promise updateUser: (id: string, data: Partial) => Promise } analytics: { track: (event: string, data?: object) => void } } // Create context const DepsContext = createContext(null) // Provider function AppProvider({ deps, children }: { deps: AppDependencies; children: ReactNode }) { return {children} } // Hook to use dependencies function useDeps(): AppDependencies { const deps = useContext(DepsContext) if (!deps) throw new Error('Missing AppProvider') return deps } ``` ### Use in Components ```typescript function UserProfile({ userId }: { userId: string }) { const { api, analytics } = useDeps() const [user, setUser] = useState>(notAsked()) useEffect(() => { setUser(loading()) api.getUser(userId) .then(u => { setUser(success(u)) analytics.track('user_viewed', { userId }) }) .catch(e => setUser(failure(e))) }, [userId, api, analytics]) // render... } ``` ### Testing with Mock Dependencies ```typescript const mockDeps: AppDependencies = { api: { getUser: jest.fn().mockResolvedValue({ id: '1', name: 'Test User' }), updateUser: jest.fn().mockResolvedValue({ id: '1', name: 'Updated' }), }, analytics: { track: jest.fn(), }, } test('loads user on mount', async () => { render( ) await screen.findByText('Test User') expect(mockDeps.api.getUser).toHaveBeenCalledWith('1') }) ``` --- ## 7. React 19 Patterns ### use() for Promises (React 19+) ```typescript import { use, Suspense } from 'react' // Instead of useEffect + useState for data fetching function UserProfile({ userPromise }: { userPromise: Promise }) { const user = use(userPromise) // Suspends until resolved return
{user.name}
} // Parent provides the promise function App() { const userPromise = fetchUser('1') // Start fetching immediately return ( }> ) } ``` ### useActionState for Forms (React 19+) ```typescript import { useActionState } from 'react' import * as E from 'fp-ts/Either' interface FormState { errors: string[] success: boolean } async function submitForm( prevState: FormState, formData: FormData ): Promise { const data = { email: formData.get('email') as string, password: formData.get('password') as string, } // Use Either for validation const result = pipe( validateForm(data), E.match( (errors) => ({ errors, success: false }), async (valid) => { await saveToServer(valid) return { errors: [], success: true } } ) ) return result } function SignupForm() { const [state, formAction, isPending] = useActionState(submitForm, { errors: [], success: false }) return (
{state.errors.map(e =>

{e}

)}
) } ``` ### useOptimistic for Instant Feedback (React 19+) ```typescript import { useOptimistic } from 'react' function TodoList({ todos }: { todos: Todo[] }) { const [optimisticTodos, addOptimisticTodo] = useOptimistic( todos, (state, newTodo: Todo) => [...state, { ...newTodo, pending: true }] ) const addTodo = async (text: string) => { const newTodo = { id: crypto.randomUUID(), text, done: false } // Immediately show in UI addOptimisticTodo(newTodo) // Actually save (will reconcile when done) await saveTodo(newTodo) } return (
    {optimisticTodos.map(todo => (
  • {todo.text}
  • ))}
) } ``` --- ## 8. Common Patterns Cheat Sheet ### Render Based on Option ```typescript // Pattern 1: match pipe( maybeUser, O.match( () => , (user) => ) ) // Pattern 2: fold (same as match) O.fold( () => , (user) => )(maybeUser) // Pattern 3: getOrElse for simple defaults const name = pipe( maybeUser, O.map(u => u.name), O.getOrElse(() => 'Guest') ) ``` ### Render Based on Either ```typescript pipe( validationResult, E.match( (errors) => , (data) => ) ) ``` ### Safe Array Rendering ```typescript import * as A from 'fp-ts/Array' // Get first item safely const firstUser = pipe( users, A.head, O.map(user => ), O.getOrElse(() => ) ) // Find specific item const adminUser = pipe( users, A.findFirst(u => u.role === 'admin'), O.map(admin => ), O.toNullable // or O.getOrElse(() => null) ) ``` ### Conditional Props ```typescript // Add props only if value exists const modalProps = { isOpen: true, ...pipe( maybeTitle, O.map(title => ({ title })), O.getOrElse(() => ({})) ) } ``` --- ## When to Use What | Situation | Use | |-----------|-----| | Value might not exist | `Option` | | Operation might fail (sync) | `Either` | | Async operation might fail | `TaskEither` | | Need loading/error/success UI | `RemoteData` | | Form with multiple validations | `Either` with validation applicative | | Dependency injection | Context + `ReaderTaskEither` | | Prevent re-renders with fp-ts | `useMemo` or `fp-ts-react-stable-hooks` | --- ## Libraries - **[fp-ts](https://github.com/gcanti/fp-ts)** - Core library - **[fp-ts-react-stable-hooks](https://github.com/mblink/fp-ts-react-stable-hooks)** - Stable hooks - **[@devexperts/remote-data-ts](https://github.com/devexperts/remote-data-ts)** - RemoteData - **[io-ts](https://github.com/gcanti/io-ts)** - Runtime type validation - **[zod](https://github.com/colinhacks/zod)** - Schema validation (works great with fp-ts)