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
1---2name: fp-ts-react3description: 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.4risk: safe5source: https://github.com/whatiskadudoing/fp-ts-skills6---78# Functional Programming in React910Practical patterns for React apps. No jargon, just code that works.1112## When to Use This Skill1314- When building React apps with fp-ts for type-safe state management15- When handling loading/error/success states in data fetching16- When implementing form validation with error accumulation17- When using React 18/19 or Next.js 14/15 with functional patterns1819---2021## Quick Reference2223| Pattern | Use When |24|---------|----------|25| `Option` | Value might be missing (user not loaded yet) |26| `Either` | Operation might fail (form validation) |27| `TaskEither` | Async operation might fail (API calls) |28| `RemoteData` | Need to show loading/error/success states |29| `pipe` | Chaining multiple transformations |3031---3233## 1. State with Option (Maybe It's There, Maybe Not)3435Use `Option` instead of `null | undefined` for clearer intent.3637### Basic Pattern3839```typescript40import { useState } from 'react'41import * as O from 'fp-ts/Option'42import { pipe } from 'fp-ts/function'4344interface User {45 id: string46 name: string47 email: string48}4950function UserProfile() {51 // Option says "this might not exist yet"52 const [user, setUser] = useState<O.Option<User>>(O.none)5354 const handleLogin = (userData: User) => {55 setUser(O.some(userData))56 }5758 const handleLogout = () => {59 setUser(O.none)60 }6162 return pipe(63 user,64 O.match(65 // When there's no user66 () => <button onClick={() => handleLogin({ id: '1', name: 'Alice', email: 'alice@example.com' })}>67 Log In68 </button>,69 // When there's a user70 (u) => (71 <div>72 <p>Welcome, {u.name}!</p>73 <button onClick={handleLogout}>Log Out</button>74 </div>75 )76 )77 )78}79```8081### Chaining Optional Values8283```typescript84import * as O from 'fp-ts/Option'85import { pipe } from 'fp-ts/function'8687interface Profile {88 user: O.Option<{89 name: string90 settings: O.Option<{91 theme: string92 }>93 }>94}9596function getTheme(profile: Profile): string {97 return pipe(98 profile.user,99 O.flatMap(u => u.settings),100 O.map(s => s.theme),101 O.getOrElse(() => 'light') // default102 )103}104```105106---107108## 2. Form Validation with Either109110Either is perfect for validation: `Left` = errors, `Right` = valid data.111112### Simple Form Validation113114```typescript115import * as E from 'fp-ts/Either'116import * as A from 'fp-ts/Array'117import { pipe } from 'fp-ts/function'118119// Validation functions return Either<ErrorMessage, ValidValue>120const validateEmail = (email: string): E.Either<string, string> =>121 email.includes('@')122 ? E.right(email)123 : E.left('Invalid email address')124125const validatePassword = (password: string): E.Either<string, string> =>126 password.length >= 8127 ? E.right(password)128 : E.left('Password must be at least 8 characters')129130const validateName = (name: string): E.Either<string, string> =>131 name.trim().length > 0132 ? E.right(name.trim())133 : E.left('Name is required')134```135136### Collecting All Errors (Not Just First One)137138```typescript139import * as E from 'fp-ts/Either'140import { sequenceS } from 'fp-ts/Apply'141import { getSemigroup } from 'fp-ts/NonEmptyArray'142import { pipe } from 'fp-ts/function'143144// This collects ALL errors, not just the first one145const validateAll = sequenceS(E.getApplicativeValidation(getSemigroup<string>()))146147interface SignupForm {148 name: string149 email: string150 password: string151}152153interface ValidatedForm {154 name: string155 email: string156 password: string157}158159function validateForm(form: SignupForm): E.Either<string[], ValidatedForm> {160 return pipe(161 validateAll({162 name: pipe(validateName(form.name), E.mapLeft(e => [e])),163 email: pipe(validateEmail(form.email), E.mapLeft(e => [e])),164 password: pipe(validatePassword(form.password), E.mapLeft(e => [e])),165 })166 )167}168169// Usage in component170function SignupForm() {171 const [form, setForm] = useState({ name: '', email: '', password: '' })172 const [errors, setErrors] = useState<string[]>([])173174 const handleSubmit = () => {175 pipe(176 validateForm(form),177 E.match(178 (errs) => setErrors(errs), // Show all errors179 (valid) => {180 setErrors([])181 submitToServer(valid) // Submit valid data182 }183 )184 )185 }186187 return (188 <form onSubmit={e => { e.preventDefault(); handleSubmit() }}>189 <input190 value={form.name}191 onChange={e => setForm(f => ({ ...f, name: e.target.value }))}192 placeholder="Name"193 />194 <input195 value={form.email}196 onChange={e => setForm(f => ({ ...f, email: e.target.value }))}197 placeholder="Email"198 />199 <input200 type="password"201 value={form.password}202 onChange={e => setForm(f => ({ ...f, password: e.target.value }))}203 placeholder="Password"204 />205206 {errors.length > 0 && (207 <ul style={{ color: 'red' }}>208 {errors.map((err, i) => <li key={i}>{err}</li>)}209 </ul>210 )}211212 <button type="submit">Sign Up</button>213 </form>214 )215}216```217218### Field-Level Errors (Better UX)219220```typescript221type FieldErrors = Partial<Record<keyof SignupForm, string>>222223function validateFormWithFieldErrors(form: SignupForm): E.Either<FieldErrors, ValidatedForm> {224 const errors: FieldErrors = {}225226 pipe(validateName(form.name), E.mapLeft(e => { errors.name = e }))227 pipe(validateEmail(form.email), E.mapLeft(e => { errors.email = e }))228 pipe(validatePassword(form.password), E.mapLeft(e => { errors.password = e }))229230 return Object.keys(errors).length > 0231 ? E.left(errors)232 : E.right({ name: form.name.trim(), email: form.email, password: form.password })233}234235// In component236{errors.email && <span className="error">{errors.email}</span>}237```238239---240241## 3. Data Fetching with TaskEither242243TaskEither = async operation that might fail. Perfect for API calls.244245### Basic Fetch Hook246247```typescript248import { useState, useEffect } from 'react'249import * as TE from 'fp-ts/TaskEither'250import * as E from 'fp-ts/Either'251import { pipe } from 'fp-ts/function'252253// Wrap fetch in TaskEither254const fetchJson = <T>(url: string): TE.TaskEither<Error, T> =>255 TE.tryCatch(256 async () => {257 const res = await fetch(url)258 if (!res.ok) throw new Error(`HTTP ${res.status}`)259 return res.json()260 },261 (err) => err instanceof Error ? err : new Error(String(err))262 )263264// Custom hook265function useFetch<T>(url: string) {266 const [data, setData] = useState<T | null>(null)267 const [error, setError] = useState<Error | null>(null)268 const [loading, setLoading] = useState(true)269270 useEffect(() => {271 setLoading(true)272 setError(null)273274 pipe(275 fetchJson<T>(url),276 TE.match(277 (err) => {278 setError(err)279 setLoading(false)280 },281 (result) => {282 setData(result)283 setLoading(false)284 }285 )286 )()287 }, [url])288289 return { data, error, loading }290}291292// Usage293function UserList() {294 const { data, error, loading } = useFetch<User[]>('/api/users')295296 if (loading) return <div>Loading...</div>297 if (error) return <div>Error: {error.message}</div>298 return (299 <ul>300 {data?.map(user => <li key={user.id}>{user.name}</li>)}301 </ul>302 )303}304```305306### Chaining API Calls307308```typescript309// Fetch user, then fetch their posts310const fetchUserWithPosts = (userId: string) => pipe(311 fetchJson<User>(`/api/users/${userId}`),312 TE.flatMap(user => pipe(313 fetchJson<Post[]>(`/api/users/${userId}/posts`),314 TE.map(posts => ({ ...user, posts }))315 ))316)317```318319### Parallel API Calls320321```typescript322import { sequenceT } from 'fp-ts/Apply'323324// Fetch multiple things at once325const fetchDashboardData = () => pipe(326 sequenceT(TE.ApplyPar)(327 fetchJson<User>('/api/user'),328 fetchJson<Stats>('/api/stats'),329 fetchJson<Notifications[]>('/api/notifications')330 ),331 TE.map(([user, stats, notifications]) => ({332 user,333 stats,334 notifications335 }))336)337```338339---340341## 4. RemoteData Pattern (The Right Way to Handle Async State)342343Stop using `{ data, loading, error }` booleans. Use a proper state machine.344345### The Pattern346347```typescript348// RemoteData has exactly 4 states - no impossible combinations349type RemoteData<E, A> =350 | { _tag: 'NotAsked' } // Haven't started yet351 | { _tag: 'Loading' } // In progress352 | { _tag: 'Failure'; error: E } // Failed353 | { _tag: 'Success'; data: A } // Got it!354355// Constructors356const notAsked = <E, A>(): RemoteData<E, A> => ({ _tag: 'NotAsked' })357const loading = <E, A>(): RemoteData<E, A> => ({ _tag: 'Loading' })358const failure = <E, A>(error: E): RemoteData<E, A> => ({ _tag: 'Failure', error })359const success = <E, A>(data: A): RemoteData<E, A> => ({ _tag: 'Success', data })360361// Pattern match all states362function fold<E, A, R>(363 rd: RemoteData<E, A>,364 onNotAsked: () => R,365 onLoading: () => R,366 onFailure: (e: E) => R,367 onSuccess: (a: A) => R368): R {369 switch (rd._tag) {370 case 'NotAsked': return onNotAsked()371 case 'Loading': return onLoading()372 case 'Failure': return onFailure(rd.error)373 case 'Success': return onSuccess(rd.data)374 }375}376```377378### Hook with RemoteData379380```typescript381function useRemoteData<T>(fetchFn: () => Promise<T>) {382 const [state, setState] = useState<RemoteData<Error, T>>(notAsked())383384 const execute = async () => {385 setState(loading())386 try {387 const data = await fetchFn()388 setState(success(data))389 } catch (err) {390 setState(failure(err instanceof Error ? err : new Error(String(err))))391 }392 }393394 return { state, execute }395}396397// Usage398function UserProfile({ userId }: { userId: string }) {399 const { state, execute } = useRemoteData(() =>400 fetch(`/api/users/${userId}`).then(r => r.json())401 )402403 useEffect(() => { execute() }, [userId])404405 return fold(406 state,407 () => <button onClick={execute}>Load User</button>,408 () => <Spinner />,409 (err) => <ErrorMessage message={err.message} onRetry={execute} />,410 (user) => <UserCard user={user} />411 )412}413```414415### Why RemoteData Beats Booleans416417```typescript418// ❌ BAD: Impossible states are possible419interface BadState {420 data: User | null421 loading: boolean422 error: Error | null423}424// Can have: { data: user, loading: true, error: someError } - what does that mean?!425426// ✅ GOOD: Only valid states exist427type GoodState = RemoteData<Error, User>428// Can only be: NotAsked | Loading | Failure | Success429```430431---432433## 5. Referential Stability (Preventing Re-renders)434435fp-ts values like `O.some(1)` create new objects each render. React sees them as "changed".436437### The Problem438439```typescript440// ❌ BAD: Creates new Option every render441function BadComponent() {442 const [value, setValue] = useState(O.some(1))443444 useEffect(() => {445 // This runs EVERY render because O.some(1) !== O.some(1)446 console.log('value changed')447 }, [value])448}449```450451### Solution 1: useMemo452453```typescript454// ✅ GOOD: Memoize Option creation455function GoodComponent() {456 const [rawValue, setRawValue] = useState<number | null>(1)457458 const value = useMemo(459 () => O.fromNullable(rawValue),460 [rawValue] // Only recreate when rawValue changes461 )462463 useEffect(() => {464 // Now this only runs when rawValue actually changes465 console.log('value changed')466 }, [rawValue]) // Depend on raw value, not Option467}468```469470### Solution 2: fp-ts-react-stable-hooks471472```bash473npm install fp-ts-react-stable-hooks474```475476```typescript477import { useStableO, useStableEffect } from 'fp-ts-react-stable-hooks'478import * as O from 'fp-ts/Option'479import * as Eq from 'fp-ts/Eq'480481function StableComponent() {482 // Uses fp-ts equality instead of reference equality483 const [value, setValue] = useStableO(O.some(1))484485 // Effect that understands Option equality486 useStableEffect(487 () => { console.log('value changed') },488 [value],489 Eq.tuple(O.getEq(Eq.eqNumber)) // Custom equality490 )491}492```493494---495496## 6. Dependency Injection with Context497498Use ReaderTaskEither for testable components with injected dependencies.499500### Setup Dependencies501502```typescript503import * as RTE from 'fp-ts/ReaderTaskEither'504import { pipe } from 'fp-ts/function'505import { createContext, useContext, ReactNode } from 'react'506507// Define what services your app needs508interface AppDependencies {509 api: {510 getUser: (id: string) => Promise<User>511 updateUser: (id: string, data: Partial<User>) => Promise<User>512 }513 analytics: {514 track: (event: string, data?: object) => void515 }516}517518// Create context519const DepsContext = createContext<AppDependencies | null>(null)520521// Provider522function AppProvider({ deps, children }: { deps: AppDependencies; children: ReactNode }) {523 return <DepsContext.Provider value={deps}>{children}</DepsContext.Provider>524}525526// Hook to use dependencies527function useDeps(): AppDependencies {528 const deps = useContext(DepsContext)529 if (!deps) throw new Error('Missing AppProvider')530 return deps531}532```533534### Use in Components535536```typescript537function UserProfile({ userId }: { userId: string }) {538 const { api, analytics } = useDeps()539 const [user, setUser] = useState<RemoteData<Error, User>>(notAsked())540541 useEffect(() => {542 setUser(loading())543 api.getUser(userId)544 .then(u => {545 setUser(success(u))546 analytics.track('user_viewed', { userId })547 })548 .catch(e => setUser(failure(e)))549 }, [userId, api, analytics])550551 // render...552}553```554555### Testing with Mock Dependencies556557```typescript558const mockDeps: AppDependencies = {559 api: {560 getUser: jest.fn().mockResolvedValue({ id: '1', name: 'Test User' }),561 updateUser: jest.fn().mockResolvedValue({ id: '1', name: 'Updated' }),562 },563 analytics: {564 track: jest.fn(),565 },566}567568test('loads user on mount', async () => {569 render(570 <AppProvider deps={mockDeps}>571 <UserProfile userId="1" />572 </AppProvider>573 )574575 await screen.findByText('Test User')576 expect(mockDeps.api.getUser).toHaveBeenCalledWith('1')577})578```579580---581582## 7. React 19 Patterns583584### use() for Promises (React 19+)585586```typescript587import { use, Suspense } from 'react'588589// Instead of useEffect + useState for data fetching590function UserProfile({ userPromise }: { userPromise: Promise<User> }) {591 const user = use(userPromise) // Suspends until resolved592 return <div>{user.name}</div>593}594595// Parent provides the promise596function App() {597 const userPromise = fetchUser('1') // Start fetching immediately598599 return (600 <Suspense fallback={<Spinner />}>601 <UserProfile userPromise={userPromise} />602 </Suspense>603 )604}605```606607### useActionState for Forms (React 19+)608609```typescript610import { useActionState } from 'react'611import * as E from 'fp-ts/Either'612613interface FormState {614 errors: string[]615 success: boolean616}617618async function submitForm(619 prevState: FormState,620 formData: FormData621): Promise<FormState> {622 const data = {623 email: formData.get('email') as string,624 password: formData.get('password') as string,625 }626627 // Use Either for validation628 const result = pipe(629 validateForm(data),630 E.match(631 (errors) => ({ errors, success: false }),632 async (valid) => {633 await saveToServer(valid)634 return { errors: [], success: true }635 }636 )637 )638639 return result640}641642function SignupForm() {643 const [state, formAction, isPending] = useActionState(submitForm, {644 errors: [],645 success: false646 })647648 return (649 <form action={formAction}>650 <input name="email" type="email" />651 <input name="password" type="password" />652653 {state.errors.map(e => <p key={e} className="error">{e}</p>)}654655 <button disabled={isPending}>656 {isPending ? 'Submitting...' : 'Sign Up'}657 </button>658 </form>659 )660}661```662663### useOptimistic for Instant Feedback (React 19+)664665```typescript666import { useOptimistic } from 'react'667668function TodoList({ todos }: { todos: Todo[] }) {669 const [optimisticTodos, addOptimisticTodo] = useOptimistic(670 todos,671 (state, newTodo: Todo) => [...state, { ...newTodo, pending: true }]672 )673674 const addTodo = async (text: string) => {675 const newTodo = { id: crypto.randomUUID(), text, done: false }676677 // Immediately show in UI678 addOptimisticTodo(newTodo)679680 // Actually save (will reconcile when done)681 await saveTodo(newTodo)682 }683684 return (685 <ul>686 {optimisticTodos.map(todo => (687 <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>688 {todo.text}689 </li>690 ))}691 </ul>692 )693}694```695696---697698## 8. Common Patterns Cheat Sheet699700### Render Based on Option701702```typescript703// Pattern 1: match704pipe(705 maybeUser,706 O.match(707 () => <LoginButton />,708 (user) => <UserMenu user={user} />709 )710)711712// Pattern 2: fold (same as match)713O.fold(714 () => <LoginButton />,715 (user) => <UserMenu user={user} />716)(maybeUser)717718// Pattern 3: getOrElse for simple defaults719const name = pipe(720 maybeUser,721 O.map(u => u.name),722 O.getOrElse(() => 'Guest')723)724```725726### Render Based on Either727728```typescript729pipe(730 validationResult,731 E.match(732 (errors) => <ErrorList errors={errors} />,733 (data) => <SuccessMessage data={data} />734 )735)736```737738### Safe Array Rendering739740```typescript741import * as A from 'fp-ts/Array'742743// Get first item safely744const firstUser = pipe(745 users,746 A.head,747 O.map(user => <Featured user={user} />),748 O.getOrElse(() => <NoFeaturedUser />)749)750751// Find specific item752const adminUser = pipe(753 users,754 A.findFirst(u => u.role === 'admin'),755 O.map(admin => <AdminBadge user={admin} />),756 O.toNullable // or O.getOrElse(() => null)757)758```759760### Conditional Props761762```typescript763// Add props only if value exists764const modalProps = {765 isOpen: true,766 ...pipe(767 maybeTitle,768 O.map(title => ({ title })),769 O.getOrElse(() => ({}))770 )771}772```773774---775776## When to Use What777778| Situation | Use |779|-----------|-----|780| Value might not exist | `Option<T>` |781| Operation might fail (sync) | `Either<E, A>` |782| Async operation might fail | `TaskEither<E, A>` |783| Need loading/error/success UI | `RemoteData<E, A>` |784| Form with multiple validations | `Either` with validation applicative |785| Dependency injection | Context + `ReaderTaskEither` |786| Prevent re-renders with fp-ts | `useMemo` or `fp-ts-react-stable-hooks` |787788---789790## Libraries791792- **[fp-ts](https://github.com/gcanti/fp-ts)** - Core library793- **[fp-ts-react-stable-hooks](https://github.com/mblink/fp-ts-react-stable-hooks)** - Stable hooks794- **[@devexperts/remote-data-ts](https://github.com/devexperts/remote-data-ts)** - RemoteData795- **[io-ts](https://github.com/gcanti/io-ts)** - Runtime type validation796- **[zod](https://github.com/colinhacks/zod)** - Schema validation (works great with fp-ts)797
Full transparency — inspect the skill content before installing.