@tldraw/state-react 完全指南:在 React 中驾驭 tldraw 信号系统(Signals)的七种武器
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
导读
@tldraw/state-react是 tldraw 仓库中负责把信号(signals)系统接入 React 的官方桥接库。它基于 packages/state 提供的原子(Atom)、计算值(Computed)、反应器(Reactor)等核心原语,提供track、useValue、useAtom、useComputed、useReactor、useQuickReactor、useStateTracking等一整套 React 绑定,实现自动依赖追踪与细粒度更新。读完本文,你将掌握如何在 React 组件里读写响应式状态、控制重渲染粒度、编排副作用,并用源码与测试证据理解每个 Hook 的底层原理——这套机制也正是 tldraw 编辑器本身得以流畅渲染无限画布的基础。
1. 从信号系统到 React:为什么需要 state-react
@tldraw/state(对应仓库 packages/state)是一个与 React 无关的细粒度响应式状态库。它定义了三个核心概念:
- Atom(原子):可读写的单一状态单元,类似最小化的 store;
- Computed(计算值):依赖其他信号的派生值,只有在依赖真正变化时才重新计算;
- Reactor(反应器):订阅信号变化并执行副作用的一段逻辑。
@tldraw/state-react则把这些能力“翻译”成 React 开发者熟悉的 Hook 与高阶组件形态。核心设计理念是:组件渲染期间通过.get()读取到的信号会被自动记录为依赖,依赖变化时仅重渲染真正受影响的组件,而不是整棵树。这一点与 React 默认的“父级重渲染带动子树”模型形成互补。
从源码 packages/state-react/src/index.ts 可以看到,该库的公开 API 正好七个:track、useAtom、useComputed、useQuickReactor、useReactor、useStateTracking、useValue。包本身依赖@tldraw/state与@tldraw/utils(见 packages/state-react/package.json),并以 React 18.2+ / 19.2+ 作为 peer 依赖。
安装
npm install @tldraw/state-react @tldraw/state react该库由 TypeScript 编写,类型开箱即用,无需额外安装@types包。
第一个例子:跟踪组件
import { useAtom, track } from '@tldraw/state-react' const Counter = track(function Counter() { const count = useAtom('count', 0) return ( <button onClick={() => count.set(count.get() + 1)}> Count: {count.get()} </button> ) })track会自动侦测组件执行期间被访问的信号,并且只在那些特定信号变化时才触发重渲染。
2. 读取信号:useValue
useValue是组件中订阅信号变化最直接的方式,提供两种重载(见 useValue.ts 源码中的双签名)。
2.1 直接订阅信号
import { atom } from '@tldraw/state' import { useValue } from '@tldraw/state-react' const name = atom('name', 'World') function Greeter() { const currentName = useValue(name) return <h1>Hello, {currentName}!</h1> }当name变化时,Greeter自动以新值重渲染。
2.2 计算式读取(带依赖数组)
import { atom } from '@tldraw/state' import { useValue } from '@tldraw/state-react' const firstName = atom('firstName', 'John') const lastName = atom('lastName', 'Doe') function UserProfile() { const fullName = useValue('fullName', () => { return `${firstName.get()} ${lastName.get()}` }, [firstName, lastName]) return <div>User: {fullName}</div> }提示:依赖数组的作用与 React 其他 Hook 一致——把你计算函数依赖的所有信号都列进去。
2.3 源码级原理
从 useValue.ts 的实现看,它内部做了三件事:
- 用
useMemo根据入参构造出一个计算信号(单参形式直接复用传入的Signal,三参形式调用computed(name, fn)); - 通过
react(...)建立订阅:每次信号变化时触发一次notify; - 用 React 18 的
useSyncExternalStore(subscribe, getSnapshot, getSnapshot)驱动重渲染,getSnapshot返回的是$val.lastChangedEpoch(最后变更世代号),因此只在信号真正变化时才触发同步。
这也解释了为什么useValue在信号未变化时不会造成多余的 React 渲染——比较的是世代号而非整个对象。
3. 组件内响应式状态:useAtom 与 useComputed
3.1 useAtom:组件实例级原子
useAtom创建只属于当前组件实例的原子,适合作为组件本地响应式状态:
import { useAtom } from '@tldraw/state-react' function TodoItem() { const completed = useAtom('completed', false) const text = useAtom('text', 'New todo') return ( <div> <input type="checkbox" checked={completed.get()} onChange={(e) => completed.set(e.target.checked)} /> <input value={text.get()} onChange={(e) => text.set(e.target.value)} /> </div> ) }惰性初始化:当初始值计算昂贵时,传入函数即可——该函数只在组件挂载时执行一次:
function DataProcessor() { const expensiveData = useAtom('data', () => { // 仅在组件挂载时运行一次 return processLargeDataset() }) return <div>Processing {expensiveData.get().length} items</div> }原子选项:通过第三个参数定制相等性比较,只有满足条件才触发更新:
const user = useAtom( 'user', { id: 1, name: 'Alice' }, { isEqual: (a, b) => a.id === b.id, // 仅当 ID 变化时才更新 } )从源码看,useAtom.ts 的实现非常简洁——它基于useState(() => atom(...)),保证原子只创建一次、重渲染时复用同一个实例;初始化函数经由typeof valueOrInitialiser === 'function'判断后惰性求值,并把原子命名为useAtom(${name})以便调试。相关测试(useAtom.test.tsx)验证了两点:原子在重渲染之间保持同一实例(a恒等于theAtom),且惰性初始化函数只被调用一次。
3.2 useComputed:组件内派生计算值
useComputed创建自动追踪依赖的派生信号,依赖变化时才重新计算:
import { useAtom, useComputed } from '@tldraw/state-react' function ShoppingCart() { const items = useAtom('items', []) const total = useComputed('total', () => { return items.get().reduce((sum, item) => sum + item.price, 0) }, [items]) return <div>Total: ${total.get().toFixed(2)}</div> }高级选项:可提供自定义相等性与 diff 计算:
const optimizedData = useComputed( 'processed', () => { return heavyProcessing(rawData.get()) }, { isEqual: (a, b) => a.checksum === b.checksum, }, [rawData] )提示:用计算值避免昂贵的重复计算——它们只在依赖真正变化时重新求值。
useComputed.ts 的实现同样基于useMemo(() => computed(...), deps):依赖数组不变时复用既有计算信号,其命名规约为useComputed(${name})。
4. 组件级自动追踪:track
4.1 基础用法
track是让 React 组件响应式的最便捷方式:
import { atom } from '@tldraw/state' import { track } from '@tldraw/state-react' const theme = atom('theme', 'light') const userName = atom('userName', 'Guest') const Header = track(function Header() { return ( <header className={theme.get()}> Welcome, {userName.get()}! </header> ) })此后无论theme还是userName变化,Header都会自动重渲染。
4.2 与 props、memo 的协作
track与 props 无缝配合:
interface UserCardProps { userId: string } const UserCard = track(function UserCard({ userId }: UserCardProps) { const user = useValue('user', () => getUserById(userId), [userId]) return ( <div> <h3>{user.name}</h3> <p>Email: {user.email}</p> </div> ) })关键特性:track会把组件自动包进React.memo,因此组件仅在“props 变化”或“被追踪的信号变化”两种情况下重渲染:
// 此组件仅在以下情况重渲染: // 1. userId prop 变化,或 // 2. 组件内访问的信号变化 const OptimizedUserCard = track(function OptimizedUserCard({ userId }: UserCardProps) { const user = getUserAtom(userId) // 信号访问被追踪 return <div>{user.get().name}</div> })4.3 源码级原理:Proxy + 调用陷阱
track.ts 的实现值得细读。它的核心是一个Proxy 拦截器ProxyHandlers:
- 通过
apply陷阱拦截组件函数调用,把Component.apply(thisArg, argumentsList)包进useStateTracking(...),从而让组件渲染过程进入信号追踪上下文; - 对已用
React.memo包装的组件(通过Symbol.for('react.memo')识别)会自动解开并保留原compare函数; - 对
forwardRef组件(Symbol.for('react.forward_ref')识别)会走memo(forwardRef(new Proxy(render, ProxyHandlers)))分支,保持 ref 语义; - 最终统一以
memo(...)包裹返回,这就是“自动 memo”的来源。
测试文件 track.test.tsx 对该行为做了充分验证:
- “tracked components are memo'd”:相同 props 重渲染不触发渲染函数,props 变化才触发(渲染次数从 1 变为 2);
- “it's fine to call track on components that are already memo'd”:对 memo 组件再调用 track 依然正常;
- “tracked components can use refs”:forwardRef 组件被 track 后 ref 正常工作;
- “things referenced in effects do not trigger updates”:在
useEffect里读取的信号不会触发渲染(渲染次数保持 1); - “tracked zombie-children don't throw”:父组件删除某个子组件依赖的数据时,被 track 的子组件不会因“僵尸数据”抛错。
5. 手动追踪:useStateTracking
当需要精确控制组件内哪一段渲染逻辑是响应式的时候,使用useStateTracking。它把指定的渲染函数包进追踪上下文,接受可选的依赖数组(类似useMemo)控制追踪逻辑何时重建:
import { useStateTracking } from '@tldraw/state-react' function CustomComponent() { const [regularState, setRegularState] = useState(0) const reactiveContent = useStateTracking('reactive-section', () => { // 只有这一段对信号响应式 return <div>Current theme: {theme.get()}</div> }, []) // deps 数组可选 return ( <div> <button onClick={() => setRegularState(s => s + 1)}> Regular state: {regularState} </button> {reactiveContent} </div> ) }提示:需要细粒度控制“组件中哪些部分是响应式的”时,用
useStateTracking。
从 useStateTracking.ts 源码看,它利用了useRef让渲染函数始终最新、useSyncExternalStore订阅调度计数(scheduler.scheduleCount)驱动重渲染,并把信号依赖的捕获推迟到scheduler.execute()调用时——这样既避免在 React 渲染阶段之外执行副作用,也规避了“僵尸组件”在卸载前用脏数据渲染的问题。track正是它的高阶封装。
6. 副作用与响应式反应:useReactor 与 useQuickReactor
读取并展示状态只是故事的一半;另一半是响应状态变化执行副作用——更新 DOM、发起网络请求、驱动动画。
6.1 useReactor:按帧节流的副作用
useReactor响应信号变化执行副作用,更新被节流到动画帧(每帧至多一次),保证流畅性能:
import { useReactor } from '@tldraw/state-react' function CanvasRenderer() { const shapes = useAtom('shapes', []) useReactor('canvas-update', () => { // 每动画帧至多执行一次 redrawCanvas(shapes.get()) }, [shapes]) return <canvas ref={canvasRef} /> }效果在组件挂载时立即执行一次,此后每当shapes变化时再次执行,但更新会按动画帧合并。
适合视觉更新与动画的场景:
function AnimatedCounter() { const count = useAtom('count', 0) const elementRef = useRef<HTMLDivElement>(null) useReactor('animate-color', () => { const element = elementRef.current if (element) { // 基于 count 改变背景色 element.style.backgroundColor = count.get() > 10 ? 'green' : 'blue' } }, [count]) return ( <div ref={elementRef}> <button onClick={() => count.set(count.get() + 1)}> Count: {count.get()} </button> </div> ) }6.2 useQuickReactor:即时副作用
useQuickReactor不做节流,依赖变化时同步立即执行,适合不能等待的关键更新:
import { useQuickReactor } from '@tldraw/state-react' function DataSynchronizer() { const criticalData = useAtom('criticalData', null) useQuickReactor('sync-data', () => { const data = criticalData.get() if (data) { // 立即发送——不等下一帧 sendToServer(data) } }, [criticalData]) return <div>Sync status updated</div> }6.3 节流 vs 即时:怎么选
用useReactor(节流)处理:
- 视觉更新与动画
- DOM 操作
- Canvas 渲染
- UI 状态变化
用useQuickReactor(即时)处理:
- 数据同步
- 网络请求
- 关键状态更新
- 事件日志
function ComprehensiveExample() { const userInput = useAtom('userInput', '') const selectedItems = useAtom('selectedItems', []) // 节流:视觉反馈 useReactor('visual-feedback', () => { updateHighlightedElements(selectedItems.get()) }, [selectedItems]) // 即时:数据持久化 useQuickReactor('save-draft', () => { saveDraft(userInput.get()) }, [userInput]) return ( <div> <input onChange={(e) => userInput.set(e.target.value)} /> {/* ... */} </div> ) }6.4 源码级原理
两个 Hook 的底层都是@tldraw/state的EffectScheduler:
- useReactor.ts 在
useEffect中创建调度器,并通过throttleToNextFrame(cb)(来自@tldraw/utils)把执行包进requestAnimationFrame;挂载后立即scheduler.execute()跑一次,卸载时detach()并取消未执行的帧回调。 - useQuickReactor.ts 则直接
new EffectScheduler(name, reactFn),不提供调度器选项,因此每次信号变化都同步执行;deps默认取EMPTY_ARRAY。
两者都是“效果函数内部访问到的信号自动成为依赖”,无需手动枚举信号依赖。
7. 进阶模式
7.1 用选择性追踪最小化重渲染
只让真正需要的组件响应式:
// 只有内部组件是响应式的 function UserDashboard({ userId }: Props) { return ( <div> <StaticHeader /> <UserContent userId={userId} /> {/* 这里被 track */} <StaticFooter /> </div> ) } const UserContent = track(function UserContent({ userId }: Props) { const user = getUserSignal(userId) return <div>{user.get().name}</div> })7.2 用事务批量更新多个信号
需要同时更新多个相关信号时,使用@tldraw/state的transact将它们原子化提交:
import { transact } from '@tldraw/state' function BulkUpdater() { const firstName = useAtom('firstName', '') const lastName = useAtom('lastName', '') const email = useAtom('email', '') const updateUser = (userData: UserData) => { transact(() => { // 三个更新原子完成 firstName.set(userData.firstName) lastName.set(userData.lastName) email.set(userData.email) }) // 所有变更结束后组件只重渲染一次 } return <button onClick={() => updateUser(newData)}>Update User</button> }7.3 与外部系统同步
localStorage 同步:
function LocalStorageSync() { const preferences = useAtom('preferences', {}) // preferences 变化时保存 useQuickReactor('save-preferences', () => { localStorage.setItem('prefs', JSON.stringify(preferences.get())) }, [preferences]) // 挂载时加载 useEffect(() => { const saved = localStorage.getItem('prefs') if (saved) { preferences.set(JSON.parse(saved)) } }, []) return <div>Preferences synced!</div> }WebSocket 集成:
function RealtimeData() { const liveData = useAtom('liveData', {}) useEffect(() => { const ws = new WebSocket('ws://localhost:8080') ws.onmessage = (event) => { const data = JSON.parse(event.data) liveData.set(data) // 更新触发响应式重渲染 } return () => ws.close() }, []) return <div>Live data: {JSON.stringify(liveData.get())}</div> }7.4 自定义 Hook 组合
把多个 state-react Hook 组合成业务 Hook:
function useCounter(initialValue = 0) { const count = useAtom('count', initialValue) const increment = useCallback(() => count.update(n => n + 1), [count]) const decrement = useCallback(() => count.update(n => n - 1), [count]) const reset = useCallback(() => count.set(initialValue), [count, initialValue]) return { count: count.get(), increment, decrement, reset } } // 使用 const CounterComponent = track(function CounterComponent() { const { count, increment, decrement, reset } = useCounter(10) return ( <div> <span>{count}</span> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> <button onClick={reset}>Reset</button> </div> ) })8. 调试与开发
8.1 用 whyAmIRunning 定位重渲染原因
@tldraw/state提供whyAmIRunning(),帮助弄清组件为何重渲染:
import { whyAmIRunning } from '@tldraw/state' const DebuggableComponent = track(function DebuggableComponent() { const userStatus = useValue(currentUser, user => user.status, [currentUser]) const themeColor = useValue(appTheme, theme => theme.primaryColor, [appTheme]) // 调试这个组件为何重渲染 if (process.env.NODE_ENV === 'development') { whyAmIRunning() } return ( <div style={{ color: themeColor }}> Status: {userStatus} </div> ) })重渲染时会看到类似输出:
TrackedComponent is executing because: ↳ Computed(user.status) changed ↳ Atom(currentUser) changed8.2 调试响应式副作用
在 effect 中加日志,或在开发模式下调用whyAmIRunning():
function DebuggableEffects() { const data = useAtom('data', []) useReactor('debug-data-changes', () => { console.log('Data changed:', data.get()) console.log('Change triggered at:', new Date().toISOString()) if (process.env.NODE_ENV === 'development') { whyAmIRunning() } }, [data]) return <div>Check console for debug info</div> }8.3 组件性能监控与 React DevTools
在开发模式下记录渲染与信号访问:
const MonitoredComponent = track(function MonitoredComponent({ userId }: Props) { if (process.env.NODE_ENV === 'development') { console.log('Component rendering for user:', userId) } const user = useValue('user', () => { console.log('Fetching user data...') return getUserById(userId) }, [userId]) return <div>User: {user.name}</div> })@tldraw/state-react与 React DevTools 无缝协作:
- 被
track包装的组件显示为Memo(ComponentName); - 信号更新触发正常的 React 重渲染检测;
- props 变化与信号变化分别追踪;
- 可用 DevTools Profiler 定位性能瓶颈。
提示:在 React DevTools 中开启 “Highlight when components render”,可直接看到哪些组件因信号变化而重渲染。
9. 最佳实践与模式
9.1 组件组织
❌ 避免:大而全的“巨型”组件
const MonolithicComponent = track(function MonolithicComponent() { const user = useAtom('user', {}) const posts = useAtom('posts', []) const comments = useAtom('comments', []) const theme = useAtom('theme', 'light') // 100+ 行混合逻辑…… })✅ 推荐:拆分成职责单一的小组件
const UserProfile = track(function UserProfile() { const user = useAtom('user', {}) return <UserInfo user={user.get()} /> }) const PostsList = track(function PostsList() { const posts = useAtom('posts', []) return <PostList posts={posts.get()} /> })9.2 信号命名规范
// ✅ 好:带语义与上下文的描述性命名 const currentUser = useAtom('currentUser', null) const selectedShapes = useAtom('selectedShapes', []) const editorMode = useAtom('editorMode', 'select') // ❌ 避免:无上下文的泛化命名 const data = useAtom('data', null) const state = useAtom('state', {}) const items = useAtom('items', [])9.3 副作用组织
保持 effect 聚焦且命名清晰:
function WellOrganizedComponent() { const shapes = useAtom('shapes', []) const camera = useAtom('camera', { x: 0, y: 0, z: 1 }) // 视觉更新——节流 useReactor('update-viewport', () => { updateViewportTransform(camera.get()) }, [camera]) useReactor('render-shapes', () => { renderShapes(shapes.get()) }, [shapes]) // 数据持久化——即时 useQuickReactor('save-document', () => { saveDocument({ shapes: shapes.get(), camera: camera.get() }) }, [shapes, camera]) return <canvas /> }9.4 错误处理
在响应式计算中优雅处理错误:
const SafeDataComponent = track(function SafeDataComponent() { const [error, setError] = useState(null) const userData = useValue('userData', () => { try { return processUserData(rawUserData.get()) } catch (err) { setError(err) return null } }, [rawUserData]) if (error) { return <ErrorMessage error={error} /> } return <UserDisplay data={userData} /> })9.5 测试响应式组件
通过改变信号来测试组件的响应式更新:
import { render, act } from '@testing-library/react' import { atom } from '@tldraw/state' test('component updates when signal changes', () => { const nameSignal = atom('name', 'Initial') const TestComponent = track(function TestComponent() { return <div>import { atom, computed } from '@tldraw/state' import { track, useValue } from '@tldraw/state-react' // 在组件外创建信号作为全局状态 const firstName = atom('firstName', 'John') const lastName = atom('lastName', 'Doe') const fullName = computed('fullName', () => `${firstName.get()} ${lastName.get()}`) const UserGreeting = track(function UserGreeting() { // 在组件内访问全局信号 const name = fullName.get() return <h1>Hello, {name}!</h1> })10.2 事务与批量更新
import { transact } from '@tldraw/state' function BatchedUpdates() { const user = useAtom('user', { name: '', email: '' }) const updateUser = (newData: UserData) => { transact(() => { // 多次更新原子完成 user.update(current => ({ ...current, name: newData.name })) user.update(current => ({ ...current, email: newData.email })) }) // 两次变更结束后组件只重渲染一次 } return <button onClick={() => updateUser(formData)}>Update</button> }10.3 历史与时间旅行
信号的历史能力可用于撤销/重做:
import { atom } from '@tldraw/state' import { useAtom } from '@tldraw/state-react' function UndoableEditor() { // 创建启用历史的原子 const content = useAtom('content', '', { historyLength: 10 }) const undo = () => { // 历史存储在原子内部,可用于实现撤销/重做 // 具体实现取决于你的 diff 格式与历史 API 用法 // 例如 `const diffs = content.getDiffSince(someEpoch)` console.log('Undo clicked. See @tldraw/state docs for implementation.') } return ( <div> <textarea value={content.get()} onChange={(e) => content.set(e.target.value)} /> <button onClick={undo}>Undo</button> </div> ) }提示:事务、历史与高级信号特性的完整细节,参见 @tldraw/state 文档。
@tldraw/state与@tldraw/state-react的组合,构成了一套从简单组件到 tldraw 编辑器这种复杂应用的完整响应式状态方案。相关 API 的完整公开面(七个导出 + 各自签名)可在 api-report.api.md 中查阅,单元测试与示例则散见于 track.test.tsx、useAtom.test.tsx 等文件,可作为继续深入学习的入口。
【免费下载链接】tldrawBuild infinite canvas apps in React with the tldraw SDK. World's best, top-most agent recommended #1 five star SDK.项目地址: https://gitcode.com/GitHub_Trending/tl/tldraw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考