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.
Add this skill
npx mdskills install sickn33/fp-ts-reactComprehensive fp-ts patterns with clear examples and practical React integration guidance
Practical patterns for React apps. No jargon, just code that works.
| 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 |
Use Option instead of null | undefined for clearer intent.
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
() => handleLogin({ id: '1', name: 'Alice', email: 'alice@example.com' })}>
Log In
,
// When there's a user
(u) => (
Welcome, {u.name}!
Log Out
)
)
)
}
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'
interface Profile {
user: O.Option
}>
}
function getTheme(profile: Profile): string {
return pipe(
profile.user,
O.flatMap(u => u.settings),
O.map(s => s.theme),
O.getOrElse(() => 'light') // default
)
}
Either is perfect for validation: Left = errors, Right = valid data.
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')
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})}
)}
Sign Up
)
}
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}}
TaskEither = async operation that might fail. Perfect for API calls.
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})}
)
}
// 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 }))
))
)
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
}))
)
Stop using { data, loading, error } booleans. Use a proper state machine.
// 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)
}
}
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,
() => Load User,
() => ,
(err) => ,
(user) =>
)
}
// ❌ 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
fp-ts values like O.some(1) create new objects each render. React sees them as "changed".
// ❌ 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])
}
// ✅ 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
}
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
)
}
Use ReaderTaskEither for testable components with injected 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
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
}
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...
}
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')
})
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 (
}>
)
}
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}
)}
{isPending ? 'Submitting...' : 'Sign Up'}
)
}
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}
))}
)
}
// 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')
)
pipe(
validationResult,
E.match(
(errors) => ,
(data) =>
)
)
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)
)
// Add props only if value exists
const modalProps = {
isOpen: true,
...pipe(
maybeTitle,
O.map(title => ({ title })),
O.getOrElse(() => ({}))
)
}
| 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 |
Install via CLI
npx mdskills install sickn33/fp-ts-reactFp Ts React is a free, open-source AI agent skill. 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.
Install Fp Ts React with a single command:
npx mdskills install sickn33/fp-ts-reactThis downloads the skill files into your project and your AI agent picks them up automatically.
Fp Ts React works with Claude Code, Claude Desktop, Cursor, Vscode Copilot, Windsurf, Continue Dev, Codex, Gemini Cli, Amp, Roo Code, Goose, Opencode, Trae, Qodo, Command Code. Skills use the open SKILL.md format which is compatible with any AI coding agent that reads markdown instructions.