fp-react
Practical patterns for using fp-ts with React - hooks, state, forms, data fetching. Works with React 18/19, Next.js 14/15.
- risk
- unknown
- source
- community
- version
- 2.0.0
- author
- fp-ts-skills
- tags
- [fp-ts, react, typescript, hooks, state-management, forms, data-fetching, remote-data, react-19, next-js]
Functional Programming in React
Practical patterns for React apps. No jargon, just code that works.
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
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.Option<User>>(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 () => <button onClick={() => handleLogin({ id: '1', name: 'Alice', email: 'alice@example.com' })}> Log In </button>, // When there's a user (u) => ( <div> <p>Welcome, {u.name}!</p> <button onClick={handleLogout}>Log Out</button> </div> ) ) ) }
Chaining Optional Values
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
import * as E from 'fp-ts/Either' import * as A from 'fp-ts/Array' import { pipe } from 'fp-ts/function' // Validation functions return Either<ErrorMessage, ValidValue> const validateEmail = (email: string): E.Either<string, string> => email.includes('@') ? E.right(email) : E.left('Invalid email address') const validatePassword = (password: string): E.Either<string, string> => password.length >= 8 ? E.right(password) : E.left('Password must be at least 8 characters') const validateName = (name: string): E.Either<string, string> => name.trim().length > 0 ? E.right(name.trim()) : E.left('Name is required')
Collecting All Errors (Not Just First One)
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<string>())) interface SignupForm { name: string email: string password: string } interface ValidatedForm { name: string email: string password: string } function validateForm(form: SignupForm): E.Either<string[], ValidatedForm> { 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<string[]>([]) const handleSubmit = () => { pipe( validateForm(form), E.match( (errs) => setErrors(errs), // Show all errors (valid) => { setErrors([]) submitToServer(valid) // Submit valid data } ) ) } return ( <form onSubmit={e => { e.preventDefault(); handleSubmit() }}> <input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Name" /> <input value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} placeholder="Email" /> <input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))} placeholder="Password" /> {errors.length > 0 && ( <ul style={{ color: 'red' }}> {errors.map((err, i) => <li key={i}>{err}</li>)} </ul> )} <button type="submit">Sign Up</button> </form> ) }
Field-Level Errors (Better UX)
type FieldErrors = Partial<Record<keyof SignupForm, string>> function validateFormWithFieldErrors(form: SignupForm): E.Either<FieldErrors, ValidatedForm> { 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 && <span className="error">{errors.email}</span>}
3. Data Fetching with TaskEither
TaskEither = async operation that might fail. Perfect for API calls.
Basic Fetch Hook
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 = <T>(url: string): TE.TaskEither<Error, T> => 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<T>(url: string) { const [data, setData] = useState<T | null>(null) const [error, setError] = useState<Error | null>(null) const [loading, setLoading] = useState(true) useEffect(() => { setLoading(true) setError(null) pipe( fetchJson<T>(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<User[]>('/api/users') if (loading) return <div>Loading...</div> if (error) return <div>Error: {error.message}</div> return ( <ul> {data?.map(user => <li key={user.id}>{user.name}</li>)} </ul> ) }
Chaining API Calls
// Fetch user, then fetch their posts const fetchUserWithPosts = (userId: string) => pipe( fetchJson<User>(`/api/users/${userId}`), TE.flatMap(user => pipe( fetchJson<Post[]>(`/api/users/${userId}/posts`), TE.map(posts => ({ ...user, posts })) )) )
Parallel API Calls
import { sequenceT } from 'fp-ts/Apply' // Fetch multiple things at once const fetchDashboardData = () => pipe( sequenceT(TE.ApplyPar)( fetchJson<User>('/api/user'), fetchJson<Stats>('/api/stats'), fetchJson<Notifications[]>('/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
// RemoteData has exactly 4 states - no impossible combinations type RemoteData<E, A> = | { _tag: 'NotAsked' } // Haven't started yet | { _tag: 'Loading' } // In progress | { _tag: 'Failure'; error: E } // Failed | { _tag: 'Success'; data: A } // Got it! // Constructors const notAsked = <E, A>(): RemoteData<E, A> => ({ _tag: 'NotAsked' }) const loading = <E, A>(): RemoteData<E, A> => ({ _tag: 'Loading' }) const failure = <E, A>(error: E): RemoteData<E, A> => ({ _tag: 'Failure', error }) const success = <E, A>(data: A): RemoteData<E, A> => ({ _tag: 'Success', data }) // Pattern match all states function fold<E, A, R>( rd: RemoteData<E, A>, 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
function useRemoteData<T>(fetchFn: () => Promise<T>) { const [state, setState] = useState<RemoteData<Error, T>>(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, () => <button onClick={execute}>Load User</button>, () => <Spinner />, (err) => <ErrorMessage message={err.message} onRetry={execute} />, (user) => <UserCard user={user} /> ) }
Why RemoteData Beats Booleans
// ❌ 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<Error, User> // 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
// ❌ 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
// ✅ GOOD: Memoize Option creation function GoodComponent() { const [rawValue, setRawValue] = useState<number | null>(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
npm install fp-ts-react-stable-hooks
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
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<User> updateUser: (id: string, data: Partial<User>) => Promise<User> } analytics: { track: (event: string, data?: object) => void } } // Create context const DepsContext = createContext<AppDependencies | null>(null) // Provider function AppProvider({ deps, children }: { deps: AppDependencies; children: ReactNode }) { return <DepsContext.Provider value={deps}>{children}</DepsContext.Provider> } // Hook to use dependencies function useDeps(): AppDependencies { const deps = useContext(DepsContext) if (!deps) throw new Error('Missing AppProvider') return deps }
Use in Components
function UserProfile({ userId }: { userId: string }) { const { api, analytics } = useDeps() const [user, setUser] = useState<RemoteData<Error, User>>(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
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( <AppProvider deps={mockDeps}> <UserProfile userId="1" /> </AppProvider> ) await screen.findByText('Test User') expect(mockDeps.api.getUser).toHaveBeenCalledWith('1') })
7. React 19 Patterns
use() for Promises (React 19+)
import { use, Suspense } from 'react' // Instead of useEffect + useState for data fetching function UserProfile({ userPromise }: { userPromise: Promise<User> }) { const user = use(userPromise) // Suspends until resolved return <div>{user.name}</div> } // Parent provides the promise function App() { const userPromise = fetchUser('1') // Start fetching immediately return ( <Suspense fallback={<Spinner />}> <UserProfile userPromise={userPromise} /> </Suspense> ) }
useActionState for Forms (React 19+)
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<FormState> { 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 ( <form action={formAction}> <input name="email" type="email" /> <input name="password" type="password" /> {state.errors.map(e => <p key={e} className="error">{e}</p>)} <button disabled={isPending}> {isPending ? 'Submitting...' : 'Sign Up'} </button> </form> ) }
useOptimistic for Instant Feedback (React 19+)
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 ( <ul> {optimisticTodos.map(todo => ( <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}> {todo.text} </li> ))} </ul> ) }
8. Common Patterns Cheat Sheet
Render Based on Option
// Pattern 1: match pipe( maybeUser, O.match( () => <LoginButton />, (user) => <UserMenu user={user} /> ) ) // Pattern 2: fold (same as match) O.fold( () => <LoginButton />, (user) => <UserMenu user={user} /> )(maybeUser) // Pattern 3: getOrElse for simple defaults const name = pipe( maybeUser, O.map(u => u.name), O.getOrElse(() => 'Guest') )
Render Based on Either
pipe( validationResult, E.match( (errors) => <ErrorList errors={errors} />, (data) => <SuccessMessage data={data} /> ) )
Safe Array Rendering
import * as A from 'fp-ts/Array' // Get first item safely const firstUser = pipe( users, A.head, O.map(user => <Featured user={user} />), O.getOrElse(() => <NoFeaturedUser />) ) // Find specific item const adminUser = pipe( users, A.findFirst(u => u.role === 'admin'), O.map(admin => <AdminBadge user={admin} />), O.toNullable // or O.getOrElse(() => null) )
Conditional Props
// 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<T> |
| Operation might fail (sync) | Either<E, A> |
| Async operation might fail | TaskEither<E, A> |
| Need loading/error/success UI | RemoteData<E, A> |
| 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 - Core library
- fp-ts-react-stable-hooks - Stable hooks
- @devexperts/remote-data-ts - RemoteData
- io-ts - Runtime type validation
- zod - Schema validation (works great with fp-ts)