fp-ts-react

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.

View Source
name:fp-ts-reactdescription: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:safesource: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

    PatternUse When
    OptionValue might be missing (user not loaded yet)
    EitherOperation might fail (form validation)
    TaskEitherAsync operation might fail (API calls)
    RemoteDataNeed to show loading/error/success states
    pipeChaining 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) {
    <div class="overflow-x-auto my-6"><table class="min-w-full divide-y divide-border border border-border"><thead><tr><th class="px-4 py-2 text-left text-sm font-semibold text-foreground bg-muted/50">const [data, setData] = useState&lt;T</th><th class="px-4 py-2 text-left text-sm font-semibold text-foreground bg-muted/50">null&gt;(null)</th></tr></thead><tbody class="divide-y divide-border"></tbody></table></div>
    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> =
    <div class="overflow-x-auto my-6"><table class="min-w-full divide-y divide-border border border-border"><thead><tr><th class="px-4 py-2 text-left text-sm font-semibold text-foreground bg-muted/50">{ _tag: &#039;NotAsked&#039; } // Haven&#039;t started yet</th></tr></thead><tbody class="divide-y divide-border"><tr><td class="px-4 py-2 text-sm text-foreground">{ _tag: &#039;Failure&#039;; error: E } // Failed</td></tr><tr><td class="px-4 py-2 text-sm text-foreground">{ _tag: &#039;Success&#039;; data: A } // Got it!</td></tr></tbody></table></div>

    // 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

    SituationUse
    Value might not existOption<T>
    Operation might fail (sync)Either<E, A>
    Async operation might failTaskEither<E, A>
    Need loading/error/success UIRemoteData<E, A>
    Form with multiple validationsEither with validation applicative
    Dependency injectionContext + ReaderTaskEither
    Prevent re-renders with fp-tsuseMemo 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)

    1. fp-ts-react - Agent Skills