fp-ts-react
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFunctional Programming in React
React中的函数式编程
Practical patterns for React apps. No jargon, just code that works.
适用于React应用的实用模式。没有晦涩术语,只有可运行的代码。
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
- 当使用fp-ts构建类型安全的React应用状态管理时
- 当处理数据获取中的加载/错误/成功状态时
- 当实现带错误收集的表单验证时
- 当在React 18/19或Next.js 14/15中使用函数式模式时
Quick Reference
快速参考
| Pattern | Use When |
|---|---|
| Value might be missing (user not loaded yet) |
| Operation might fail (form validation) |
| Async operation might fail (API calls) |
| Need to show loading/error/success states |
| Chaining multiple transformations |
| 模式 | 使用场景 |
|---|---|
| 值可能缺失(用户尚未加载) |
| 操作可能失败(表单验证) |
| 异步操作可能失败(API调用) |
| 需要展示加载/错误/成功状态 |
| 链式调用多个转换操作 |
1. State with Option (Maybe It's There, Maybe Not)
1. 使用Option管理状态(值可能存在或不存在)
Use instead of for clearer intent.
Optionnull | undefined使用替代,让意图更清晰。
Optionnull | undefinedBasic 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.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>
)
)
)
}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表示“该值可能还不存在”
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(
// 当没有用户时
() => <button onClick={() => handleLogin({ id: '1', name: 'Alice', email: 'alice@example.com' })}>
登录
</button>,
// 当有用户时
(u) => (
<div>
<p>欢迎,{u.name}!</p>
<button onClick={handleLogout}>登出</button>
</div>
)
)
)
}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
)
}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') // 默认值
)
}2. Form Validation with Either
2. 使用Either进行表单验证
Either is perfect for validation: = errors, = valid data.
LeftRightEither非常适合验证场景:代表错误,代表有效数据。
LeftRightSimple 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<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')typescript
import * as E from 'fp-ts/Either'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'
// 验证函数返回Either<错误信息, 有效值>
const validateEmail = (email: string): E.Either<string, string> =>
email.includes('@')
? E.right(email)
: E.left('无效的邮箱地址')
const validatePassword = (password: string): E.Either<string, string> =>
password.length >= 8
? E.right(password)
: E.left('密码长度至少为8个字符')
const validateName = (name: string): E.Either<string, string> =>
name.trim().length > 0
? E.right(name.trim())
: E.left('姓名为必填项')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<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>
)
}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'
// 收集所有错误,而非仅第一个
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])),
})
)
}
// 在组件中使用
function SignupForm() {
const [form, setForm] = useState({ name: '', email: '', password: '' })
const [errors, setErrors] = useState<string[]>([])
const handleSubmit = () => {
pipe(
validateForm(form),
E.match(
(errs) => setErrors(errs), // 展示所有错误
(valid) => {
setErrors([])
submitToServer(valid) // 提交有效数据
}
)
)
}
return (
<form onSubmit={e => { e.preventDefault(); handleSubmit() }}>
<input
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="姓名"
/>
<input
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
placeholder="邮箱"
/>
<input
type="password"
value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
placeholder="密码"
/>
{errors.length > 0 && (
<ul style={{ color: 'red' }}>
{errors.map((err, i) => <li key={i}>{err}</li>)}
</ul>
)}
<button type="submit">注册</button>
</form>
)
}Field-Level Errors (Better UX)
字段级错误(更优的用户体验)
typescript
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>}typescript
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 })
}
// 在组件中
{errors.email && <span className="error">{errors.email}</span>}3. Data Fetching with TaskEither
3. 使用TaskEither进行数据获取
TaskEither = async operation that might fail. Perfect for API calls.
TaskEither = 可能失败的异步操作,非常适合API调用。
Basic Fetch Hook
基础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 = <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>
)
}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'
// 将fetch包装为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))
)
// 自定义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 }
}
// 使用示例
function UserList() {
const { data, error, loading } = useFetch<User[]>('/api/users')
if (loading) return <div>加载中...</div>
if (error) return <div>错误:{error.message}</div>
return (
<ul>
{data?.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
)
}Chaining API Calls
链式API调用
typescript
// 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 }))
))
)typescript
// 获取用户,然后获取其帖子
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
并行API调用
typescript
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
}))
)typescript
import { sequenceT } from 'fp-ts/Apply'
// 同时获取多个数据
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)
4. RemoteData模式(处理异步状态的正确方式)
Stop using booleans. Use a proper state machine.
{ data, loading, error }不要再使用布尔值组合。使用合适的状态机。
{ data, loading, error }The Pattern
模式定义
typescript
// 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)
}
}typescript
// RemoteData包含且仅包含4种状态——不存在不可能的组合
type RemoteData<E, A> =
| { _tag: 'NotAsked' } // 尚未开始
| { _tag: 'Loading' } // 进行中
| { _tag: 'Failure'; error: E } // 失败
| { _tag: 'Success'; data: A } // 成功获取数据
// 构造函数
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 })
// 模式匹配所有状态
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
结合RemoteData的Hook
typescript
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} />
)
}typescript
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 }
}
// 使用示例
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}>加载用户</button>,
() => <Spinner />,
(err) => <ErrorMessage message={err.message} onRetry={execute} />,
(user) => <UserCard user={user} />
)
}Why RemoteData Beats Booleans
为什么RemoteData优于布尔值组合
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<Error, User>
// Can only be: NotAsked | Loading | Failure | Successtypescript
// ❌ 糟糕:存在不可能的状态
interface BadState {
data: User | null
loading: boolean
error: Error | null
}
// 可能出现:{ data: user, loading: true, error: someError }——这到底是什么意思?!
// ✅ 优秀:仅存在有效状态
type GoodState = RemoteData<Error, User>
// 只能是:NotAsked | Loading | Failure | Success5. Referential Stability (Preventing Re-renders)
5. 引用稳定性(避免不必要的重渲染)
fp-ts values like create new objects each render. React sees them as "changed".
O.some(1)像这样的fp-ts值会在每次渲染时创建新对象,React会将其视为“已更改”。
O.some(1)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])
}typescript
// ❌ 糟糕:每次渲染都会创建新的Option
function BadComponent() {
const [value, setValue] = useState(O.some(1))
useEffect(() => {
// 因为O.some(1) !== O.some(1),所以每次渲染都会执行
console.log('value已更改')
}, [value])
}Solution 1: useMemo
解决方案1:useMemo
typescript
// ✅ 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
}typescript
// ✅ 良好:缓存Option的创建
function GoodComponent() {
const [rawValue, setRawValue] = useState<number | null>(1)
const value = useMemo(
() => O.fromNullable(rawValue),
[rawValue] // 仅当rawValue更改时重新创建
)
useEffect(() => {
// 现在仅当rawValue实际更改时才会执行
console.log('value已更改')
}, [rawValue]) // 依赖原始值,而非Option
}Solution 2: fp-ts-react-stable-hooks
解决方案2:fp-ts-react-stable-hooks
bash
npm install fp-ts-react-stable-hookstypescript
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
)
}bash
npm install fp-ts-react-stable-hookstypescript
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() {
// 使用fp-ts的相等性而非引用相等性
const [value, setValue] = useStableO(O.some(1))
// 理解Option相等性的Effect
useStableEffect(
() => { console.log('value已更改') },
[value],
Eq.tuple(O.getEq(Eq.eqNumber)) // 自定义相等性
)
}6. Dependency Injection with Context
6. 使用Context进行依赖注入
Use ReaderTaskEither for testable components with injected dependencies.
使用ReaderTaskEither实现可测试的依赖注入组件。
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<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
}typescript
import * as RTE from 'fp-ts/ReaderTaskEither'
import { pipe } from 'fp-ts/function'
import { createContext, useContext, ReactNode } from 'react'
// 定义应用所需的服务
interface AppDependencies {
api: {
getUser: (id: string) => Promise<User>
updateUser: (id: string, data: Partial<User>) => Promise<User>
}
analytics: {
track: (event: string, data?: object) => void
}
}
// 创建Context
const DepsContext = createContext<AppDependencies | null>(null)
// 提供者组件
function AppProvider({ deps, children }: { deps: AppDependencies; children: ReactNode }) {
return <DepsContext.Provider value={deps}>{children}</DepsContext.Provider>
}
// 获取依赖的Hook
function useDeps(): AppDependencies {
const deps = useContext(DepsContext)
if (!deps) throw new Error('缺少AppProvider')
return deps
}Use in Components
在组件中使用
typescript
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...
}typescript
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])
// 渲染逻辑...
}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(
<AppProvider deps={mockDeps}>
<UserProfile userId="1" />
</AppProvider>
)
await screen.findByText('Test User')
expect(mockDeps.api.getUser).toHaveBeenCalledWith('1')
})typescript
const mockDeps: AppDependencies = {
api: {
getUser: jest.fn().mockResolvedValue({ id: '1', name: '测试用户' }),
updateUser: jest.fn().mockResolvedValue({ id: '1', name: '已更新' }),
},
analytics: {
track: jest.fn(),
},
}
test('挂载时加载用户', async () => {
render(
<AppProvider deps={mockDeps}>
<UserProfile userId="1" />
</AppProvider>
)
await screen.findByText('测试用户')
expect(mockDeps.api.getUser).toHaveBeenCalledWith('1')
})7. React 19 Patterns
7. React 19专属模式
use() for Promises (React 19+)
使用use()处理Promise(React 19+)
typescript
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>
)
}typescript
import { use, Suspense } from 'react'
// 替代useEffect + useState处理数据获取
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // 会挂起直到Promise解析
return <div>{user.name}</div>
}
// 父组件提供Promise
function App() {
const userPromise = fetchUser('1') // 立即开始获取数据
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}useActionState for Forms (React 19+)
使用useActionState处理表单(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<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>
)
}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<FormState> {
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
// 使用Either进行验证
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 ? '提交中...' : '注册'}
</button>
</form>
)
}useOptimistic for Instant Feedback (React 19+)
使用useOptimistic实现即时反馈(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 (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
</li>
))}
</ul>
)
}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 }
// 立即在UI中展示
addOptimisticTodo(newTodo)
// 实际保存(完成后会自动协调)
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
8. 常用模式速查表
Render Based on Option
基于Option的渲染
typescript
// 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')
)typescript
// 模式1:match
pipe(
maybeUser,
O.match(
() => <LoginButton />,
(user) => <UserMenu user={user} />
)
)
// 模式2:fold(与match功能相同)
O.fold(
() => <LoginButton />,
(user) => <UserMenu user={user} />
)(maybeUser)
// 模式3:getOrElse用于简单默认值
const name = pipe(
maybeUser,
O.map(u => u.name),
O.getOrElse(() => '访客')
)Render Based on Either
基于Either的渲染
typescript
pipe(
validationResult,
E.match(
(errors) => <ErrorList errors={errors} />,
(data) => <SuccessMessage data={data} />
)
)typescript
pipe(
validationResult,
E.match(
(errors) => <ErrorList errors={errors} />,
(data) => <SuccessMessage data={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 => <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)
)typescript
import * as A from 'fp-ts/Array'
// 安全获取第一个元素
const firstUser = pipe(
users,
A.head,
O.map(user => <Featured user={user} />),
O.getOrElse(() => <NoFeaturedUser />)
)
// 查找特定元素
const adminUser = pipe(
users,
A.findFirst(u => u.role === 'admin'),
O.map(admin => <AdminBadge user={admin} />),
O.toNullable // 或O.getOrElse(() => null)
)Conditional Props
条件式Props
typescript
// Add props only if value exists
const modalProps = {
isOpen: true,
...pipe(
maybeTitle,
O.map(title => ({ title })),
O.getOrElse(() => ({}))
)
}typescript
// 仅当值存在时添加Props
const modalProps = {
isOpen: true,
...pipe(
maybeTitle,
O.map(title => ({ title })),
O.getOrElse(() => ({}))
)
}When to Use What
模式选择指南
| Situation | Use |
|---|---|
| Value might not exist | |
| Operation might fail (sync) | |
| Async operation might fail | |
| Need loading/error/success UI | |
| Form with multiple validations | |
| Dependency injection | Context + |
| Prevent re-renders with fp-ts | |
| 场景 | 使用模式 |
|---|---|
| 值可能不存在 | |
| 同步操作可能失败 | |
| 异步操作可能失败 | |
| 需要展示加载/错误/成功UI | |
| 带多规则验证的表单 | 结合验证applicative的 |
| 依赖注入 | Context + |
| 避免fp-ts导致的重渲染 | |
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)
- fp-ts - 核心库
- fp-ts-react-stable-hooks - 稳定Hook库
- @devexperts/remote-data-ts - RemoteData实现库
- io-ts - 运行时类型验证库
- zod - Schema验证库(与fp-ts兼容良好)