在 React 中使用 fp-ts 的实用模式——hooks、状态、表单、数据获取。在构建使用函数式编程模式的 React 应用时使用。兼容 React 18/
在 React 中运用函数式编程是一项面向实际任务的技能,主要用于针对 React 应用的实用模式;没有黑话,只有能用的代码;
实现带错误累积的表单校验;使用 React 18/19 或 Next.js 14/15 配合函数式模式;Option;值可能缺失(用户尚未加。它将相关步骤、工具调用和结果整理方式集中到统一流程中,帮助使用者更快完成目标并减少重复操作。从功能定位来看,该技能强调把分散的操作要求整理成清晰、可复用的处理流程,使用户能够围绕既定目标快速准备输入、选择执行方式并获得结构化结果。
实际使用前应先确认任务范围、数据来源、运行环境、必要权限和关键参数,再依据技能说明逐步执行;若输入条件不完整,应先补齐信息或采用保守配置,避免因错误假设导致结果偏离需求。执行过程中需要关注工具调用是否成功、接口或依赖是否可用、输出格式是否符合预期,并对异常提示、缺失字段和边界情况进行处理;涉及批量任务时,还应保存进度,避免中断后重复操作。该技能适合用于一次性任务,也可以接入自动化工作流,与其他技能或上层代理配合完成更完整的业务链路;在组合使用时,应明确每一步的输入输出关系,并避免不同步骤之间出现参数冲突。
针对 React 应用的实用模式。没有黑话,只有能用的代码。
| 模式 | 使用场景 |
|---|---|
Option |
值可能缺失(用户尚未加载) |
Either |
操作可能失败(表单校验) |
TaskEither |
异步操作可能失败(API 调用) |
RemoteData |
需要展示 loading/error/success 状态 |
pipe |
链式调用多个变换 |
使用 Option 而非 null | undefined,以表达更清晰的意图。
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.none)
const handleLogin = (userData: User) => {
setUser(O.some(userData))
}
const handleLogout = () => {
setUser(O.none)
}
return pipe(
user,
O.match(
// 当没有用户时
() => ,
// 当有用户时
(u) => (
Welcome, {u.name}!
)
)
)
}
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') // 默认值
)
}
Either 非常适合校验:Left = 错误,Right = 合法数据。
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 =>
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'
// 这会收集全部错误,而非仅第一个
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])),
})
)
}
// 在组件中使用
function SignupForm() {
const [form, setForm] = useState({ name: '', email: '', password: '' })
const [errors, setErrors] = useState([])
const handleSubmit = () => {
pipe(
validateForm(form),
E.match(
(errs) => setErrors(errs), // 展示所有错误
(valid) => {
setErrors([])
submitToServer(valid) // 提交合法数据
}
)
)
}
return (
)
}
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 }) } // 在组件中 {errors.email && {errors.email}}
TaskEither = 可能失败的异步操作。非常适合 API 调用。
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 = (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))
)
// 自定义 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 }
}
// 使用
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}
)}
)
}
// 获取用户,再获取其帖子 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'
// 一次性获取多个数据
const fetchDashboardData = () => pipe(
sequenceT(TE.ApplicativePar)(
fetchJson('/api/user'),
fetchJson('/api/stats'),
fetchJson('/api/notifications')
),
TE.map(([user, stats, notifications]) => ({
user,
stats,
notifications
}))
)
别再使用 { data, loading, error } 布尔值了。使用一个恰当的状态机。
// RemoteData 有且仅有 4 种状态 —— 不可能出现非法组合 type RemoteData= | { _tag: 'NotAsked' } // 还没开始 | { _tag: 'Loading' } // 进行中 | { _tag: 'Failure'; error: E } // 失败了 | { _tag: 'Success'; data: A } // 拿到了! // 构造函数 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 }) // 对所有状态进行模式匹配 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 } } // 使用 function UserProfile({ userId }: { userId: string }) { const { state, execute } = useRemoteData(() => fetch(`/api/users/${userId}`).then(r => r.json()) ) useEffect(() => { execute() }, [userId]) return fold( state, () => , () => , (err) => , (user) => ) }
// ❌ 差:可能产生不可能的状态
interface BadState {
data: User | null
loading: boolean
error: Error | null
}
// 可能出现:{ data: user, loading: true, error: someError } —— 这算什么?!
// ✅ 好:只存在合法状态
type GoodState = RemoteData
// 只能是:NotAsked | Loading | Failure | Success
像 O.some(1) 这样的 fp-ts 值会在每次渲染时创建新对象。React 会认为它们"变了"。
// ❌ 差:每次渲染都创建新 Option
function BadComponent() {
const [value, setValue] = useState(O.some(1))
useEffect(() => {
// 这个会每次渲染都运行,因为 O.some(1) !== O.some(1)
console.log('value changed')
}, [value])
}
// ✅ 好:把 Option 的创建记忆化
function GoodComponent() {
const [rawValue, setRawValue] = useState(1)
const value = useMemo(
() => O.fromNullable(rawValue),
[rawValue] // 只有 rawValue 变化时才重建
)
useEffect(() => {
// 现在这个只在 rawValue 真正变化时才运行
console.log('value changed')
}, [rawValue]) // 依赖原始值,而非 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() {
// 使用 fp-ts 的等值判断,而非引用等值
const [value, setValue] = useStableO(O.some(1))
// 能理解 Option 等值的 effect
useStableEffect(
() => { console.log('value changed') },
[value],
Eq.tuple(O.getEq(Eq.eqNumber)) // 自定义等值
)
}
使用 ReaderTaskEither 来构建可测试的、依赖被注入的组件。
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
updateUser: (id: string, data: Partial) => Promise
}
analytics: {
track: (event: string, data?: object) => void
}
}
// 创建 context
const DepsContext = createContext(null)
// Provider
function AppProvider({ deps, children }: { deps: AppDependencies; children: ReactNode }) {
return {children}
}
// 使用依赖的 hook
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])
// 渲染……
}
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'
// 不再用 useEffect + useState 来获取数据
function UserProfile({ userPromise }: { userPromise: Promise }) {
const user = use(userPromise) // 在 resolve 前挂起
return {user.name}
}
// 父组件提供 promise
function App() {
const userPromise = fetchUser('1') // 立即开始获取
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,
}
// 使用 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 (
)
}
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 (
// 模式 1:match
pipe(
maybeUser,
O.match(
() => ,
(user) =>
)
)
// 模式 2:fold(与 match 相同)
O.fold(
() => ,
(user) =>
)(maybeUser)
// 模式 3:用 getOrElse 处理简单默认值
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' // 安全地获取第一项 const firstUser = pipe( users, A.head, O.map(user =>), O.getOrElse(() => ) ) // 查找特定项 const adminUser = pipe( users, A.findFirst(u => u.role === 'admin'), O.map(admin => ), O.toNullable // 或 O.getOrElse(() => null) )
// 仅当值存在时才添加 props
const modalProps = {
isOpen: true,
...pipe(
maybeTitle,
O.map(title => ({ title })),
O.getOrElse(() => ({}))
)
}
| 场景 | 使用 |
|---|---|
| 值可能不存在 | Option |
| 操作可能失败(同步) | Either |
| 异步操作可能失败 | TaskEither |
| 需要 loading/error/success UI | RemoteData |
| 带多重校验的表单 | 配合 validation applicative 的 Either |
| 依赖注入 | Context + ReaderTaskEither |
| 用 fp-ts 防止重复渲染 | useMemo 或 fp-ts-react-stable-hooks |