snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
819
packages/state-react/DOCS.md
Normal file
819
packages/state-react/DOCS.md
Normal file
@@ -0,0 +1,819 @@
|
||||
# @tldraw/state-react Documentation
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### What is @tldraw/state-react?
|
||||
|
||||
@tldraw/state-react is a React integration library that bridges the reactive **signals** system from @tldraw/state with React components. It provides hooks and utilities for using reactive state in React applications with automatic dependency tracking and fine-grained updates.
|
||||
|
||||
This library extends [@tldraw/state](../state/DOCS.md) with React-specific bindings, offering familiar hook patterns while maintaining the performance characteristics of the underlying signals system.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install @tldraw/state-react @tldraw/state react
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
@tldraw/state-react is written in TypeScript and provides excellent type safety out of the box. No additional types package needed.
|
||||
|
||||
### Quick Example
|
||||
|
||||
Here's a simple counter to show how @tldraw/state-react works:
|
||||
|
||||
```ts
|
||||
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>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The `track` function automatically detects when signals are accessed and re-renders the component only when those specific signals change.
|
||||
|
||||
## 2. Core Hooks
|
||||
|
||||
### useValue: Reading Signal Values
|
||||
|
||||
**useValue** is the fundamental hook for extracting values from signals and subscribing to changes. It comes in two forms: direct signal subscription and computed values.
|
||||
|
||||
#### Reading Signal Values
|
||||
|
||||
You can use `useValue` to extract the current value from any signal:
|
||||
|
||||
```ts
|
||||
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>
|
||||
}
|
||||
```
|
||||
|
||||
When `name` changes, the `Greeter` component automatically re-renders with the new value.
|
||||
|
||||
#### Creating Computed Values
|
||||
|
||||
You can also pass a function to `useValue` to create computed values with automatic dependency tracking:
|
||||
|
||||
```ts
|
||||
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>
|
||||
}
|
||||
```
|
||||
|
||||
> Tip: The dependency array works like other React hooks - include all signals that your computed function depends on.
|
||||
|
||||
### useAtom: Component-Local Reactive State
|
||||
|
||||
**useAtom** creates reactive atoms that are scoped to your component instance. This is perfect for component-local state that you want to be reactive.
|
||||
|
||||
#### Creating Local Atoms
|
||||
|
||||
```ts
|
||||
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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Lazy Initialization
|
||||
|
||||
You can pass a function as the initial value for expensive computations:
|
||||
|
||||
```ts
|
||||
function DataProcessor() {
|
||||
const expensiveData = useAtom('data', () => {
|
||||
// This function only runs once when the component mounts
|
||||
return processLargeDataset()
|
||||
})
|
||||
|
||||
return <div>Processing {expensiveData.get().length} items</div>
|
||||
}
|
||||
```
|
||||
|
||||
#### Atom Options
|
||||
|
||||
You can customize atom behavior with options:
|
||||
|
||||
```ts
|
||||
const user = useAtom(
|
||||
'user',
|
||||
{ id: 1, name: 'Alice' },
|
||||
{
|
||||
isEqual: (a, b) => a.id === b.id, // Only update if ID changes
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### useComputed: Component-Local Computed Values
|
||||
|
||||
**useComputed** creates computed signals that automatically track their dependencies and recalculate when needed.
|
||||
|
||||
#### Basic Computed Values
|
||||
|
||||
```ts
|
||||
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>
|
||||
}
|
||||
```
|
||||
|
||||
#### Advanced Computed Options
|
||||
|
||||
You can provide options for custom equality checking and diff computation:
|
||||
|
||||
```ts
|
||||
const optimizedData = useComputed(
|
||||
'processed',
|
||||
() => {
|
||||
return heavyProcessing(rawData.get())
|
||||
},
|
||||
{
|
||||
isEqual: (a, b) => a.checksum === b.checksum,
|
||||
},
|
||||
[rawData]
|
||||
)
|
||||
```
|
||||
|
||||
> Tip: Use computed values to avoid expensive recalculations. They only recalculate when their dependencies actually change.
|
||||
|
||||
## 3. Component Tracking
|
||||
|
||||
### track: Automatic Signal Tracking
|
||||
|
||||
The **track** higher-order component is the most convenient way to make React components reactive. It automatically tracks which signals your component accesses and re-renders when any of them change.
|
||||
|
||||
#### Basic Component Tracking
|
||||
|
||||
```ts
|
||||
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>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Now whenever `theme` or `userName` changes, the `Header` component automatically re-renders.
|
||||
|
||||
#### Tracking with Props
|
||||
|
||||
Track works seamlessly with component props and React patterns:
|
||||
|
||||
```ts
|
||||
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>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
#### Tracking and React.memo
|
||||
|
||||
The `track` function automatically wraps your component in `React.memo`, so it only re-renders when props change or tracked signals change:
|
||||
|
||||
```ts
|
||||
// This component only re-renders when:
|
||||
// 1. The userId prop changes, OR
|
||||
// 2. The signals accessed inside the component change
|
||||
const OptimizedUserCard = track(function OptimizedUserCard({ userId }: UserCardProps) {
|
||||
const user = getUserAtom(userId) // Signal access is tracked
|
||||
return <div>{user.get().name}</div>
|
||||
})
|
||||
```
|
||||
|
||||
### useStateTracking: Manual Tracking
|
||||
|
||||
For more control, you can use **useStateTracking** to manually track signal dependencies in specific parts of your render function. It also accepts an optional dependency array, similar to `useMemo`, to control when the reactive tracking logic is re-created.
|
||||
|
||||
```ts
|
||||
import { useStateTracking } from '@tldraw/state-react'
|
||||
|
||||
function CustomComponent() {
|
||||
const [regularState, setRegularState] = useState(0)
|
||||
|
||||
const reactiveContent = useStateTracking('reactive-section', () => {
|
||||
// Only this part is reactive to signals
|
||||
return <div>Current theme: {theme.get()}</div>
|
||||
}, []) // deps array is optional
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setRegularState(s => s + 1)}>
|
||||
Regular state: {regularState}
|
||||
</button>
|
||||
{reactiveContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> Tip: Use `useStateTracking` when you need fine-grained control over which parts of your component are reactive.
|
||||
|
||||
## 4. Side Effects and Reactions
|
||||
|
||||
Reading and displaying reactive state is only part of the story. The other part is performing **side effects** that respond to state changes - like updating the DOM, making network requests, or triggering animations.
|
||||
|
||||
### useReactor: Frame-Throttled Effects
|
||||
|
||||
**useReactor** runs side effects in response to signal changes, with updates throttled to animation frames for optimal performance:
|
||||
|
||||
```ts
|
||||
import { useReactor } from '@tldraw/state-react'
|
||||
|
||||
function CanvasRenderer() {
|
||||
const shapes = useAtom('shapes', [])
|
||||
|
||||
useReactor('canvas-update', () => {
|
||||
// This runs at most once per animation frame
|
||||
redrawCanvas(shapes.get())
|
||||
}, [shapes])
|
||||
|
||||
return <canvas ref={canvasRef} />
|
||||
}
|
||||
```
|
||||
|
||||
The effect runs immediately when the component mounts, then again whenever `shapes` changes, but updates are batched to animation frames for smooth performance.
|
||||
|
||||
#### Visual Updates and Animations
|
||||
|
||||
Use `useReactor` for any visual updates or DOM manipulations:
|
||||
|
||||
```ts
|
||||
function AnimatedCounter() {
|
||||
const count = useAtom('count', 0)
|
||||
const elementRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useReactor('animate-color', () => {
|
||||
const element = elementRef.current
|
||||
if (element) {
|
||||
// Animate background color based on 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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useQuickReactor: Immediate Effects
|
||||
|
||||
**useQuickReactor** runs side effects immediately without throttling, perfect for critical updates that can't wait:
|
||||
|
||||
```ts
|
||||
import { useQuickReactor } from '@tldraw/state-react'
|
||||
|
||||
function DataSynchronizer() {
|
||||
const criticalData = useAtom('criticalData', null)
|
||||
|
||||
useQuickReactor('sync-data', () => {
|
||||
const data = criticalData.get()
|
||||
if (data) {
|
||||
// Send immediately - don't wait for next frame
|
||||
sendToServer(data)
|
||||
}
|
||||
}, [criticalData])
|
||||
|
||||
return <div>Sync status updated</div>
|
||||
}
|
||||
```
|
||||
|
||||
#### When to Use Quick vs Throttled Effects
|
||||
|
||||
**Use `useReactor` (throttled) for:**
|
||||
|
||||
- Visual updates and animations
|
||||
- DOM manipulations
|
||||
- Canvas rendering
|
||||
- UI state changes
|
||||
|
||||
**Use `useQuickReactor` (immediate) for:**
|
||||
|
||||
- Data synchronization
|
||||
- Network requests
|
||||
- Critical state updates
|
||||
- Event logging
|
||||
|
||||
```ts
|
||||
function ComprehensiveExample() {
|
||||
const userInput = useAtom('userInput', '')
|
||||
const selectedItems = useAtom('selectedItems', [])
|
||||
|
||||
// Throttled: Visual feedback
|
||||
useReactor('visual-feedback', () => {
|
||||
updateHighlightedElements(selectedItems.get())
|
||||
}, [selectedItems])
|
||||
|
||||
// Immediate: Data persistence
|
||||
useQuickReactor('save-draft', () => {
|
||||
saveDraft(userInput.get())
|
||||
}, [userInput])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input onChange={(e) => userInput.set(e.target.value)} />
|
||||
{/* ... */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Advanced Patterns
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
While @tldraw/state-react is optimized by default, there are patterns for demanding applications.
|
||||
|
||||
#### Minimizing Re-renders with Selective Tracking
|
||||
|
||||
Use `track` strategically to only make components reactive when needed:
|
||||
|
||||
```ts
|
||||
// Only the inner component is reactive
|
||||
function UserDashboard({ userId }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<StaticHeader />
|
||||
<UserContent userId={userId} /> {/* This is tracked */}
|
||||
<StaticFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserContent = track(function UserContent({ userId }: Props) {
|
||||
const user = getUserSignal(userId)
|
||||
return <div>{user.get().name}</div>
|
||||
})
|
||||
```
|
||||
|
||||
#### Batching Updates with Multiple Signals
|
||||
|
||||
When you need to update multiple related signals, use transactions from @tldraw/state:
|
||||
|
||||
```ts
|
||||
import { transact } from '@tldraw/state'
|
||||
|
||||
function BulkUpdater() {
|
||||
const firstName = useAtom('firstName', '')
|
||||
const lastName = useAtom('lastName', '')
|
||||
const email = useAtom('email', '')
|
||||
|
||||
const updateUser = (userData: UserData) => {
|
||||
transact(() => {
|
||||
// All three updates happen atomically
|
||||
firstName.set(userData.firstName)
|
||||
lastName.set(userData.lastName)
|
||||
email.set(userData.email)
|
||||
})
|
||||
// Components re-render only once after all changes
|
||||
}
|
||||
|
||||
return <button onClick={() => updateUser(newData)}>Update User</button>
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with External State
|
||||
|
||||
#### Syncing with External Systems
|
||||
|
||||
You can use reactive effects to sync with external systems:
|
||||
|
||||
```ts
|
||||
function LocalStorageSync() {
|
||||
const preferences = useAtom('preferences', {})
|
||||
|
||||
// Save to localStorage when preferences change
|
||||
useQuickReactor('save-preferences', () => {
|
||||
localStorage.setItem('prefs', JSON.stringify(preferences.get()))
|
||||
}, [preferences])
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('prefs')
|
||||
if (saved) {
|
||||
preferences.set(JSON.parse(saved))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div>Preferences synced!</div>
|
||||
}
|
||||
```
|
||||
|
||||
#### WebSocket Integration
|
||||
|
||||
```ts
|
||||
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) // Updates trigger reactive re-renders
|
||||
}
|
||||
|
||||
return () => ws.close()
|
||||
}, [])
|
||||
|
||||
return <div>Live data: {JSON.stringify(liveData.get())}</div>
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Hook Patterns
|
||||
|
||||
You can create custom hooks that combine multiple state-react hooks:
|
||||
|
||||
```ts
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
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>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
## 6. Debugging and Development
|
||||
|
||||
Because @tldraw/state-react builds on the reactive signals from @tldraw/state, you get access to powerful debugging tools for understanding component behavior.
|
||||
|
||||
### Using whyAmIRunning
|
||||
|
||||
You can use `whyAmIRunning()` from @tldraw/state to debug why components are re-rendering:
|
||||
|
||||
```ts
|
||||
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])
|
||||
|
||||
// Debug why this component is re-rendering
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
whyAmIRunning()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ color: themeColor }}>
|
||||
Status: {userStatus}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
When the component re-renders, you'll see output like:
|
||||
|
||||
```
|
||||
TrackedComponent is executing because:
|
||||
↳ Computed(user.status) changed
|
||||
↳ Atom(currentUser) changed
|
||||
```
|
||||
|
||||
### Debugging Reactive Effects
|
||||
|
||||
You can debug reactive effects by adding logging:
|
||||
|
||||
```ts
|
||||
function DebuggableEffects() {
|
||||
const data = useAtom('data', [])
|
||||
|
||||
useReactor('debug-data-changes', () => {
|
||||
console.log('Data changed:', data.get())
|
||||
console.log('Change triggered at:', new Date().toISOString())
|
||||
|
||||
// Use whyAmIRunning to see what caused this effect
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
whyAmIRunning()
|
||||
}
|
||||
}, [data])
|
||||
|
||||
return <div>Check console for debug info</div>
|
||||
}
|
||||
```
|
||||
|
||||
### Component Performance Monitoring
|
||||
|
||||
Track renders and signal accesses in development:
|
||||
|
||||
```ts
|
||||
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])
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('User data:', user)
|
||||
}
|
||||
|
||||
return <div>User: {user.name}</div>
|
||||
})
|
||||
```
|
||||
|
||||
### React DevTools Integration
|
||||
|
||||
@tldraw/state-react works seamlessly with React DevTools:
|
||||
|
||||
- Components wrapped with `track` appear as "Memo(ComponentName)"
|
||||
- Signal updates trigger normal React re-render detection
|
||||
- Props changes are tracked separately from signal changes
|
||||
- Use React DevTools Profiler to identify performance bottlenecks
|
||||
|
||||
> Tip: Enable "Highlight when components render" in React DevTools to see which components re-render when signals change.
|
||||
|
||||
## 7. Best Practices and Patterns
|
||||
|
||||
### Component Organization
|
||||
|
||||
Structure your reactive components for maintainability:
|
||||
|
||||
```ts
|
||||
// ❌ Avoid: Large components with mixed concerns
|
||||
const MonolithicComponent = track(function MonolithicComponent() {
|
||||
const user = useAtom('user', {})
|
||||
const posts = useAtom('posts', [])
|
||||
const comments = useAtom('comments', [])
|
||||
const theme = useAtom('theme', 'light')
|
||||
|
||||
// 100+ lines of mixed logic...
|
||||
})
|
||||
|
||||
// ✅ Better: Split into focused components
|
||||
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()} />
|
||||
})
|
||||
```
|
||||
|
||||
### Signal Naming Conventions
|
||||
|
||||
Use consistent naming for clarity:
|
||||
|
||||
```ts
|
||||
// ✅ Good: Descriptive names with context
|
||||
const currentUser = useAtom('currentUser', null)
|
||||
const selectedShapes = useAtom('selectedShapes', [])
|
||||
const editorMode = useAtom('editorMode', 'select')
|
||||
|
||||
// ❌ Avoid: Generic names without context
|
||||
const data = useAtom('data', null)
|
||||
const state = useAtom('state', {})
|
||||
const items = useAtom('items', [])
|
||||
```
|
||||
|
||||
### Effect Organization
|
||||
|
||||
Keep effects focused and well-named:
|
||||
|
||||
```ts
|
||||
function WellOrganizedComponent() {
|
||||
const shapes = useAtom('shapes', [])
|
||||
const camera = useAtom('camera', { x: 0, y: 0, z: 1 })
|
||||
|
||||
// Visual updates - throttled
|
||||
useReactor('update-viewport', () => {
|
||||
updateViewportTransform(camera.get())
|
||||
}, [camera])
|
||||
|
||||
useReactor('render-shapes', () => {
|
||||
renderShapes(shapes.get())
|
||||
}, [shapes])
|
||||
|
||||
// Data persistence - immediate
|
||||
useQuickReactor('save-document', () => {
|
||||
saveDocument({ shapes: shapes.get(), camera: camera.get() })
|
||||
}, [shapes, camera])
|
||||
|
||||
return <canvas />
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Handle errors gracefully in reactive code:
|
||||
|
||||
```ts
|
||||
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} />
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Reactive Components
|
||||
|
||||
Test reactive components by testing signal changes:
|
||||
|
||||
```ts
|
||||
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 data-testid="name">{nameSignal.get()}</div>
|
||||
})
|
||||
|
||||
const { getByTestId } = render(<TestComponent />)
|
||||
|
||||
expect(getByTestId('name')).toHaveTextContent('Initial')
|
||||
|
||||
act(() => {
|
||||
nameSignal.set('Updated')
|
||||
})
|
||||
|
||||
expect(getByTestId('name')).toHaveTextContent('Updated')
|
||||
})
|
||||
```
|
||||
|
||||
## 8. Integration with @tldraw/state
|
||||
|
||||
@tldraw/state-react is designed to work seamlessly with the core @tldraw/state library. You can use all the features of @tldraw/state within React components.
|
||||
|
||||
### Using Atoms and Computed Values
|
||||
|
||||
```ts
|
||||
import { atom, computed } from '@tldraw/state'
|
||||
import { track, useValue } from '@tldraw/state-react'
|
||||
|
||||
// Create signals outside of components for global state
|
||||
const firstName = atom('firstName', 'John')
|
||||
const lastName = atom('lastName', 'Doe')
|
||||
const fullName = computed('fullName', () => `${firstName.get()} ${lastName.get()}`)
|
||||
|
||||
const UserGreeting = track(function UserGreeting() {
|
||||
// Access global signals in components
|
||||
const name = fullName.get()
|
||||
return <h1>Hello, {name}!</h1>
|
||||
})
|
||||
```
|
||||
|
||||
### Transactions and Batching
|
||||
|
||||
Use transactions to batch multiple updates:
|
||||
|
||||
```ts
|
||||
import { transact } from '@tldraw/state'
|
||||
|
||||
function BatchedUpdates() {
|
||||
const user = useAtom('user', { name: '', email: '' })
|
||||
|
||||
const updateUser = (newData: UserData) => {
|
||||
transact(() => {
|
||||
// Multiple updates happen atomically
|
||||
user.update(current => ({ ...current, name: newData.name }))
|
||||
user.update(current => ({ ...current, email: newData.email }))
|
||||
})
|
||||
// Component only re-renders once after both changes
|
||||
}
|
||||
|
||||
return <button onClick={() => updateUser(formData)}>Update</button>
|
||||
}
|
||||
```
|
||||
|
||||
### History and Time Travel
|
||||
|
||||
Access signal history for undo/redo functionality:
|
||||
|
||||
```ts
|
||||
import { atom } from '@tldraw/state'
|
||||
import { useAtom } from '@tldraw/state-react'
|
||||
|
||||
function UndoableEditor() {
|
||||
// Create an atom with history enabled
|
||||
const content = useAtom('content', '', { historyLength: 10 })
|
||||
|
||||
const undo = () => {
|
||||
// The history is stored on the atom and can be accessed
|
||||
// to implement undo/redo functionality. The exact implementation
|
||||
// depends on your diff format and how you use the history API
|
||||
// from @tldraw/state.
|
||||
// e.g. `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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> Tip: See the [@tldraw/state documentation](../state/DOCS.md) for complete details on transactions, history, and advanced signal features.
|
||||
|
||||
This powerful combination of @tldraw/state and @tldraw/state-react gives you a complete reactive state solution that scales from simple components to complex applications like the tldraw editor itself.
|
||||
9
packages/state-react/LICENSE.md
Normal file
9
packages/state-react/LICENSE.md
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 tldraw Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
33
packages/state-react/README.md
Normal file
33
packages/state-react/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @tldraw/state-react
|
||||
|
||||
React bindings for tldraw's signals library. See also the [signals library](https://github.com/tldraw/tldraw/tree/main/packages/state).
|
||||
|
||||
To learn more about this check out the [Signia library's React bindings](https://signia.tldraw.dev/docs/react-bindings) which explains the bindings and also talks about the foundational concepts.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
|
||||
|
||||
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
|
||||
|
||||
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## License
|
||||
|
||||
This project is provided under the MIT license found [here](https://github.com/tldraw/tldraw/blob/main/packages/state-react/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
4
packages/state-react/api-extractor.json
Normal file
4
packages/state-react/api-extractor.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
"extends": "../../internal/config/api-extractor.json"
|
||||
}
|
||||
44
packages/state-react/api-report.api.md
Normal file
44
packages/state-react/api-report.api.md
Normal file
@@ -0,0 +1,44 @@
|
||||
## API Report File for "@tldraw/state-react"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
|
||||
import { Atom } from '@tldraw/state';
|
||||
import { AtomOptions } from '@tldraw/state';
|
||||
import { Computed } from '@tldraw/state';
|
||||
import { ComputedOptions } from '@tldraw/state';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { Signal } from '@tldraw/state';
|
||||
|
||||
// @public
|
||||
export function track<T extends FunctionComponent<any>>(baseComponent: T): React_2.NamedExoticComponent<React_2.ComponentProps<T>>;
|
||||
|
||||
// @public
|
||||
export function useAtom<Value, Diff = unknown>(name: string, valueOrInitialiser: (() => Value) | Value, options?: AtomOptions<Value, Diff>): Atom<Value, Diff>;
|
||||
|
||||
// @public
|
||||
export function useComputed<Value>(name: string, compute: () => Value, deps: any[]): Computed<Value>;
|
||||
|
||||
// @public
|
||||
export function useComputed<Value, Diff = unknown>(name: string, compute: () => Value, opts: ComputedOptions<Value, Diff>, deps: any[]): Computed<Value>;
|
||||
|
||||
// @public
|
||||
export function useQuickReactor(name: string, reactFn: () => void, deps?: any[]): void;
|
||||
|
||||
// @public
|
||||
export function useReactor(name: string, reactFn: () => void, deps?: any[] | undefined): void;
|
||||
|
||||
// @public
|
||||
export function useStateTracking<T>(name: string, render: () => T, deps?: unknown[]): T;
|
||||
|
||||
// @public
|
||||
export function useValue<Value>(value: Signal<Value>): Value;
|
||||
|
||||
// @public
|
||||
export function useValue<Value>(name: string, fn: () => Value, deps: unknown[]): Value;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
```
|
||||
77
packages/state-react/package.json
Normal file
77
packages/state-react/package.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "@tldraw/state-react",
|
||||
"description": "tldraw infinite canvas SDK (react bindings for state).",
|
||||
"version": "5.4.2",
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "MIT",
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:sdk-core",
|
||||
"name": "SDK",
|
||||
"type": "product",
|
||||
"premium": false
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"tldraw",
|
||||
"sdk",
|
||||
"drawing",
|
||||
"app",
|
||||
"development",
|
||||
"whiteboard",
|
||||
"canvas",
|
||||
"infinite"
|
||||
],
|
||||
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./.tsbuild/index.d.ts",
|
||||
"type": "module",
|
||||
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||
"files": [],
|
||||
"scripts": {
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
||||
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
||||
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
||||
"postpack": "../../internal/scripts/postpack.sh",
|
||||
"pack-tarball": "yarn pack",
|
||||
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/state": "workspace:*",
|
||||
"@tldraw/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0 || ^19.2.1",
|
||||
"react-dom": "^18.2.0 || ^19.2.1"
|
||||
},
|
||||
"typedoc": {
|
||||
"readmeFile": "none",
|
||||
"entryPoint": "./src/index.ts",
|
||||
"displayName": "@tldraw/state",
|
||||
"tsconfig": "./tsconfig.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
14
packages/state-react/src/index.ts
Normal file
14
packages/state-react/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
||||
export { track } from './lib/track'
|
||||
export { useAtom } from './lib/useAtom'
|
||||
export { useComputed } from './lib/useComputed'
|
||||
export { useQuickReactor } from './lib/useQuickReactor'
|
||||
export { useReactor } from './lib/useReactor'
|
||||
export { useStateTracking } from './lib/useStateTracking'
|
||||
export { useValue } from './lib/useValue'
|
||||
|
||||
registerTldrawLibraryVersion(
|
||||
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
||||
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
||||
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
||||
)
|
||||
192
packages/state-react/src/lib/track.test.tsx
Normal file
192
packages/state-react/src/lib/track.test.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { RenderResult, act, render } from '@testing-library/react'
|
||||
import { atom } from '@tldraw/state'
|
||||
import { createRef, forwardRef, memo, useEffect, useImperativeHandle } from 'react'
|
||||
import { track } from './track'
|
||||
|
||||
test("tracked components are memo'd", async () => {
|
||||
let numRenders = 0
|
||||
const Component = track(function Component({ a, b, c }: { a: string; b: string; c: string }) {
|
||||
numRenders++
|
||||
return (
|
||||
<>
|
||||
{a}
|
||||
{b}
|
||||
{c}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component a="a" b="b" c="c" />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"abc"')
|
||||
|
||||
expect(numRenders).toBe(1)
|
||||
|
||||
await act(() => {
|
||||
view!.rerender(<Component a="a" b="b" c="c" />)
|
||||
})
|
||||
|
||||
expect(numRenders).toBe(1)
|
||||
|
||||
await act(() => {
|
||||
view!.rerender(<Component a="a" b="b" c="d" />)
|
||||
})
|
||||
|
||||
expect(numRenders).toBe(2)
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"abd"')
|
||||
})
|
||||
|
||||
test("it's fine to call track on components that are already memo'd", async () => {
|
||||
let numRenders = 0
|
||||
const Component = track(
|
||||
memo(function Component({ a, b, c }: { a: string; b: string; c: string }) {
|
||||
numRenders++
|
||||
return (
|
||||
<>
|
||||
{a}
|
||||
{b}
|
||||
{c}
|
||||
</>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component a="a" b="b" c="c" />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"abc"')
|
||||
|
||||
expect(numRenders).toBe(1)
|
||||
|
||||
await act(() => {
|
||||
view!.rerender(<Component a="a" b="b" c="c" />)
|
||||
})
|
||||
|
||||
expect(numRenders).toBe(1)
|
||||
|
||||
await act(() => {
|
||||
view!.rerender(<Component a="a" b="b" c="d" />)
|
||||
})
|
||||
|
||||
expect(numRenders).toBe(2)
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"abd"')
|
||||
})
|
||||
|
||||
test('tracked components can use refs', async () => {
|
||||
const Component = track(
|
||||
forwardRef<{ handle: string }, { prop: string }>(function Component({ prop }, ref) {
|
||||
useImperativeHandle(ref, () => ({ handle: prop }), [prop])
|
||||
return <>output</>
|
||||
})
|
||||
)
|
||||
|
||||
const ref = createRef<{ handle: string }>()
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component prop="hello" ref={ref} />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"output"')
|
||||
|
||||
expect(ref.current?.handle).toBe('hello')
|
||||
|
||||
await act(() => {
|
||||
view.rerender(<Component prop="world" ref={ref} />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"output"')
|
||||
|
||||
expect(ref.current?.handle).toBe('world')
|
||||
})
|
||||
|
||||
test('tracked components update when the state they reference updates', async () => {
|
||||
const a = atom('a', 1)
|
||||
|
||||
const C = track(function Component() {
|
||||
return <>{a.get()}</>
|
||||
})
|
||||
|
||||
let view: RenderResult
|
||||
|
||||
await act(() => {
|
||||
view = render(<C />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"1"`)
|
||||
|
||||
await act(() => {
|
||||
a.set(2)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"2"`)
|
||||
})
|
||||
|
||||
test('things referenced in effects do not trigger updates', async () => {
|
||||
const a = atom('a', 1)
|
||||
let numRenders = 0
|
||||
|
||||
const Component = track(function Component() {
|
||||
numRenders++
|
||||
useEffect(() => {
|
||||
a.get()
|
||||
}, [])
|
||||
return <>hi</>
|
||||
})
|
||||
|
||||
let view: RenderResult
|
||||
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"hi"`)
|
||||
expect(numRenders).toBe(1)
|
||||
|
||||
await act(() => {
|
||||
a.set(2)
|
||||
})
|
||||
|
||||
expect(numRenders).toBe(1)
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"hi"`)
|
||||
})
|
||||
|
||||
test("tracked zombie-children don't throw", async () => {
|
||||
const theAtom = atom<Record<string, number>>('map', { a: 1, b: 2, c: 3 })
|
||||
const Parent = track(function Parent() {
|
||||
const ids = Object.keys(theAtom.get())
|
||||
return (
|
||||
<>
|
||||
{ids.map((id) => (
|
||||
<Child key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
const Child = track(function Child({ id }: { id: string }) {
|
||||
if (!(id in theAtom.get())) throw new Error('id not found!')
|
||||
const value = theAtom.get()[id]
|
||||
return <>{value}</>
|
||||
})
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Parent />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"123"')
|
||||
|
||||
// remove id 'b' creating a zombie-child
|
||||
await act(() => {
|
||||
theAtom?.update(({ b: _, ...rest }) => rest)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"13"')
|
||||
})
|
||||
123
packages/state-react/src/lib/track.ts
Normal file
123
packages/state-react/src/lib/track.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { forwardRef, FunctionComponent, memo } from 'react'
|
||||
import { useStateTracking } from './useStateTracking'
|
||||
|
||||
/**
|
||||
* Proxy handlers object used to intercept function calls to React components.
|
||||
* This enables automatic signal tracking by wrapping component execution
|
||||
* in reactive tracking context.
|
||||
*
|
||||
* The proxy intercepts the function call (apply trap) and wraps it with
|
||||
* useStateTracking to enable automatic dependency tracking for signals
|
||||
* accessed during render.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Used internally by track() function
|
||||
* const ProxiedComponent = new Proxy(MyComponent, ProxyHandlers)
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const ProxyHandlers = {
|
||||
/**
|
||||
* This is a function call trap for functional components. When this is called, we know it means
|
||||
* React did run 'Component()', that means we can use any hooks here to setup our effect and
|
||||
* store.
|
||||
*
|
||||
* With the native Proxy, all other calls such as access/setting to/of properties will be
|
||||
* forwarded to the target Component, so we don't need to copy the Component's own or inherited
|
||||
* properties.
|
||||
*
|
||||
* @see https://github.com/facebook/react/blob/2d80a0cd690bb5650b6c8a6c079a87b5dc42bd15/packages/react-reconciler/src/ReactFiberHooks.old.js#L460
|
||||
*/
|
||||
apply(Component: FunctionComponent, thisArg: any, argumentsList: any) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useStateTracking(Component.displayName ?? Component.name ?? 'tracked(???)', () =>
|
||||
Component.apply(thisArg, argumentsList)
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* React internal symbol for identifying memoized components.
|
||||
* Used to detect if a component is already wrapped with React.memo().
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const isMemoComponent = component['$$typeof'] === ReactMemoSymbol
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const ReactMemoSymbol = Symbol.for('react.memo')
|
||||
|
||||
/**
|
||||
* React internal symbol for identifying forward ref components.
|
||||
* Used to detect if a component is wrapped with React.forwardRef().
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const isForwardRefComponent = component['$$typeof'] === ReactForwardRefSymbol
|
||||
* ```
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const ReactForwardRefSymbol = Symbol.for('react.forward_ref')
|
||||
|
||||
/**
|
||||
* Returns a tracked version of the given component.
|
||||
* Any signals whose values are read while the component renders will be tracked.
|
||||
* If any of the tracked signals change later it will cause the component to re-render.
|
||||
*
|
||||
* This also wraps the component in a React.memo() call, so it will only re-render
|
||||
* when props change OR when any tracked signals change. This provides optimal
|
||||
* performance by preventing unnecessary re-renders while maintaining reactivity.
|
||||
*
|
||||
* The function handles special React component types like forwardRef and memo
|
||||
* components automatically, preserving their behavior while adding reactivity.
|
||||
*
|
||||
* @param baseComponent - The React functional component to make reactive to signal changes
|
||||
* @returns A memoized component that re-renders when props or tracked signals change
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { atom } from '@tldraw/state'
|
||||
* import { track, useAtom } from '@tldraw/state-react'
|
||||
*
|
||||
* const Counter = track(function Counter(props: CounterProps) {
|
||||
* const count = useAtom('count', 0)
|
||||
* const increment = useCallback(() => count.set(count.get() + 1), [count])
|
||||
* return <button onClick={increment}>{count.get()}</button>
|
||||
* })
|
||||
*
|
||||
* // Component automatically re-renders when count signal changes
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Works with forwardRef components
|
||||
* const TrackedInput = track(React.forwardRef<HTMLInputElement, InputProps>(
|
||||
* function TrackedInput(props, ref) {
|
||||
* const theme = useValue(themeSignal)
|
||||
* return <input ref={ref} style={{ color: theme.textColor }} {...props} />
|
||||
* }
|
||||
* ))
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function track<T extends FunctionComponent<any>>(
|
||||
baseComponent: T
|
||||
): React.NamedExoticComponent<React.ComponentProps<T>> {
|
||||
let compare = null
|
||||
const $$typeof = baseComponent['$$typeof' as keyof typeof baseComponent]
|
||||
if ($$typeof === ReactMemoSymbol) {
|
||||
baseComponent = (baseComponent as any).type
|
||||
compare = (baseComponent as any).compare
|
||||
}
|
||||
if ($$typeof === ReactForwardRefSymbol) {
|
||||
return memo(forwardRef(new Proxy((baseComponent as any).render, ProxyHandlers) as any)) as any
|
||||
}
|
||||
|
||||
return memo(new Proxy(baseComponent, ProxyHandlers) as any, compare) as any
|
||||
}
|
||||
51
packages/state-react/src/lib/useAtom.test.tsx
Normal file
51
packages/state-react/src/lib/useAtom.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { act, render, RenderResult } from '@testing-library/react'
|
||||
import { Atom } from '@tldraw/state'
|
||||
import { useAtom } from './useAtom'
|
||||
import { useValue } from './useValue'
|
||||
|
||||
test('useAtom returns an atom', async () => {
|
||||
let theAtom: null | Atom<any> = null as any
|
||||
function Component() {
|
||||
const a = useAtom('myAtom', 'a')
|
||||
theAtom = a
|
||||
return <>{useValue(a)}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(theAtom).not.toBeNull()
|
||||
expect(theAtom?.get()).toBe('a')
|
||||
expect(theAtom?.name).toBe('useAtom(myAtom)')
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"a"`)
|
||||
|
||||
// it doesn't create a new atom on re-render
|
||||
const a = theAtom!
|
||||
await act(() => {
|
||||
theAtom?.set('b')
|
||||
})
|
||||
expect(a).toBe(theAtom)
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"b"`)
|
||||
})
|
||||
|
||||
test('useAtom supports taking an initializer', async () => {
|
||||
let theAtom: null | Atom<any> = null as any
|
||||
function Component() {
|
||||
const a = useAtom('myAtom', () => 'a')
|
||||
theAtom = a
|
||||
return <>{useValue(a)}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(theAtom).not.toBeNull()
|
||||
expect(theAtom?.get()).toBe('a')
|
||||
|
||||
expect(theAtom?.name).toBe('useAtom(myAtom)')
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"a"`)
|
||||
})
|
||||
35
packages/state-react/src/lib/useAtom.ts
Normal file
35
packages/state-react/src/lib/useAtom.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Atom, AtomOptions, atom } from '@tldraw/state'
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* Creates a new atom and returns it. The atom will be created only once.
|
||||
*
|
||||
* See `atom`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const Counter = track(function Counter () {
|
||||
* const count = useAtom('count', 0)
|
||||
* const increment = useCallback(() => count.set(count.get() + 1), [count])
|
||||
* return <button onClick={increment}>{count.get()}</button>
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @param name - The name of the atom. This does not need to be globally unique. It is used for debugging and performance profiling.
|
||||
* @param valueOrInitialiser - The initial value of the atom. If this is a function, it will be called to get the initial value.
|
||||
* @param options - Options for the atom.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useAtom<Value, Diff = unknown>(
|
||||
name: string,
|
||||
valueOrInitialiser: Value | (() => Value),
|
||||
options?: AtomOptions<Value, Diff>
|
||||
): Atom<Value, Diff> {
|
||||
return useState(() => {
|
||||
const initialValue =
|
||||
typeof valueOrInitialiser === 'function' ? (valueOrInitialiser as any)() : valueOrInitialiser
|
||||
|
||||
return atom(`useAtom(${name})`, initialValue, options)
|
||||
})[0]
|
||||
}
|
||||
117
packages/state-react/src/lib/useComputed.test.tsx
Normal file
117
packages/state-react/src/lib/useComputed.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { act, render, RenderResult } from '@testing-library/react'
|
||||
import { Atom, Computed } from '@tldraw/state'
|
||||
import { useState } from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useAtom } from './useAtom'
|
||||
import { useComputed } from './useComputed'
|
||||
import { useValue } from './useValue'
|
||||
|
||||
test('useComputed returns a computed value', async () => {
|
||||
let theComputed = null as null | Computed<number>
|
||||
let theAtom = null as null | Atom<number>
|
||||
function Component() {
|
||||
const a = useAtom('a', 1)
|
||||
theAtom = a
|
||||
const b = useComputed('a+1', () => a.get() + 1, [])
|
||||
theComputed = b
|
||||
return <>{useValue(b)}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(theComputed).not.toBeNull()
|
||||
expect(theComputed?.get()).toBe(2)
|
||||
expect(theComputed?.name).toBe('useComputed(a+1)')
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"2"`)
|
||||
|
||||
await act(() => {
|
||||
theAtom?.set(5)
|
||||
})
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"6"`)
|
||||
})
|
||||
|
||||
test('useComputed has a dependencies array that allows creating a new computed', async () => {
|
||||
let theComputed = null as null | Computed<number>
|
||||
let theAtom = null as null | Atom<number>
|
||||
let setCount = null as null | ((count: number) => void)
|
||||
function Component() {
|
||||
const [count, _setCount] = useState(0)
|
||||
setCount = _setCount
|
||||
const a = useAtom('a', 1)
|
||||
theAtom = a
|
||||
const b = useComputed('a+1', () => a.get() + 1, [count])
|
||||
theComputed = b
|
||||
return <>{useValue(b)}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
const initialComputed = theComputed
|
||||
|
||||
expect(theComputed).not.toBeNull()
|
||||
expect(theComputed?.get()).toBe(2)
|
||||
expect(theComputed?.name).toBe('useComputed(a+1)')
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"2"`)
|
||||
|
||||
await act(() => {
|
||||
theAtom?.set(5)
|
||||
})
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"6"`)
|
||||
|
||||
expect(initialComputed).toBe(theComputed)
|
||||
|
||||
await act(() => {
|
||||
setCount?.(2)
|
||||
})
|
||||
|
||||
expect(initialComputed).not.toBe(theComputed)
|
||||
})
|
||||
|
||||
test('useComputed allows optionally passing options', async () => {
|
||||
let theComputed = null as null | Computed<number>
|
||||
let theAtom = null as null | Atom<number>
|
||||
let setCount = null as null | ((count: number) => void)
|
||||
const isEqual = vi.fn((a, b) => a === b)
|
||||
function Component() {
|
||||
const [count, _setCount] = useState(0)
|
||||
setCount = _setCount
|
||||
const a = useAtom('a', 1)
|
||||
theAtom = a
|
||||
const b = useComputed('a+1', () => a.get() + 1, { isEqual }, [count])
|
||||
theComputed = b
|
||||
return <>{useValue(b)}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
const initialComputed = theComputed
|
||||
|
||||
expect(theComputed).not.toBeNull()
|
||||
expect(theComputed?.get()).toBe(2)
|
||||
expect(theComputed?.name).toBe('useComputed(a+1)')
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"2"`)
|
||||
|
||||
await act(() => {
|
||||
theAtom?.set(5)
|
||||
})
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"6"`)
|
||||
|
||||
expect(initialComputed).toBe(theComputed)
|
||||
|
||||
await act(() => {
|
||||
setCount?.(2)
|
||||
})
|
||||
|
||||
expect(initialComputed).not.toBe(theComputed)
|
||||
|
||||
expect(isEqual).toHaveBeenCalled()
|
||||
})
|
||||
91
packages/state-react/src/lib/useComputed.ts
Normal file
91
packages/state-react/src/lib/useComputed.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
import { Computed, ComputedOptions, computed } from '@tldraw/state'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Creates a new computed signal that automatically tracks its dependencies and recalculates when they change.
|
||||
* This overload is for basic computed values without custom options.
|
||||
*
|
||||
* @param name - A descriptive name for the computed signal, used for debugging and identification
|
||||
* @param compute - A function that computes the value, automatically tracking any signal dependencies
|
||||
* @param deps - React dependency array that controls when the computed signal is recreated
|
||||
* @returns A computed signal containing the calculated value
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const firstName = atom('firstName', 'John')
|
||||
* const lastName = atom('lastName', 'Doe')
|
||||
*
|
||||
* function UserProfile() {
|
||||
* const fullName = useComputed(
|
||||
* 'fullName',
|
||||
* () => `${firstName.get()} ${lastName.get()}`,
|
||||
* [firstName, lastName]
|
||||
* )
|
||||
*
|
||||
* return <div>Welcome, {fullName.get()}!</div>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useComputed<Value>(name: string, compute: () => Value, deps: any[]): Computed<Value>
|
||||
|
||||
/**
|
||||
* Creates a new computed signal with custom options for advanced behavior like custom equality checking,
|
||||
* diff computation, and history tracking. The computed signal will be created only once.
|
||||
*
|
||||
* @param name - A descriptive name for the computed signal, used for debugging and identification
|
||||
* @param compute - A function that computes the value, automatically tracking any signal dependencies
|
||||
* @param opts - Configuration options for the computed signal
|
||||
* - isEqual - Custom equality function to determine if the computed value has changed
|
||||
* - computeDiff - Function to compute diffs between old and new values for history tracking
|
||||
* - historyLength - Maximum number of diffs to keep in history buffer for time-travel functionality
|
||||
* @param deps - React dependency array that controls when the computed signal is recreated
|
||||
* @returns A computed signal containing the calculated value with the specified options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* function ShoppingCart() {
|
||||
* const items = useAtom('items', [])
|
||||
*
|
||||
* // Computed with custom equality to avoid recalculation for equivalent arrays
|
||||
* const sortedItems = useComputed(
|
||||
* 'sortedItems',
|
||||
* () => items.get().sort((a, b) => a.name.localeCompare(b.name)),
|
||||
* {
|
||||
* isEqual: (a, b) => a.length === b.length && a.every((item, i) => item.id === b[i].id)
|
||||
* },
|
||||
* [items]
|
||||
* )
|
||||
*
|
||||
* return <ItemList items={sortedItems.get()} />
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useComputed<Value, Diff = unknown>(
|
||||
name: string,
|
||||
compute: () => Value,
|
||||
opts: ComputedOptions<Value, Diff>,
|
||||
deps: any[]
|
||||
): Computed<Value>
|
||||
/**
|
||||
* Implementation function that handles both overloaded signatures of useComputed.
|
||||
* Uses the arguments object to dynamically determine which signature was called.
|
||||
*
|
||||
* This function creates a memoized computed signal that automatically tracks dependencies
|
||||
* and only recreates when the dependency array changes, providing optimal performance
|
||||
* in React components.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useComputed() {
|
||||
const name = arguments[0]
|
||||
const compute = arguments[1]
|
||||
const opts = arguments.length === 3 ? undefined : arguments[2]
|
||||
const deps = arguments.length === 3 ? arguments[2] : arguments[3]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(() => computed(`useComputed(${name})`, compute, opts), deps)
|
||||
}
|
||||
405
packages/state-react/src/lib/useQuickReactor.test.tsx
Normal file
405
packages/state-react/src/lib/useQuickReactor.test.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import { act, render, RenderResult } from '@testing-library/react'
|
||||
import { atom, Atom } from '@tldraw/state'
|
||||
import { useState } from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useAtom } from './useAtom'
|
||||
import { useQuickReactor } from './useQuickReactor'
|
||||
|
||||
describe('useQuickReactor', () => {
|
||||
let mockEffectFn: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
||||
let _component: () => React.JSX.Element
|
||||
let view: RenderResult
|
||||
|
||||
beforeEach(() => {
|
||||
mockEffectFn = vi.fn()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('executes the effect function immediately on mount', async () => {
|
||||
function Component() {
|
||||
useQuickReactor('test-reactor', mockEffectFn, [])
|
||||
return <div>test</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('executes the effect function immediately when tracked signals change', async () => {
|
||||
let theAtom: Atom<number>
|
||||
function Component() {
|
||||
theAtom = useAtom('counter', 0)
|
||||
useQuickReactor(
|
||||
'test-reactor',
|
||||
() => {
|
||||
mockEffectFn(theAtom.get())
|
||||
},
|
||||
[]
|
||||
)
|
||||
return <div>{theAtom.get()}</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith(0)
|
||||
|
||||
// Change the atom value - should trigger immediate effect
|
||||
await act(() => {
|
||||
theAtom!.set(5)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith(5)
|
||||
})
|
||||
|
||||
it('works with external atoms not created via useAtom', async () => {
|
||||
const externalAtom = atom('external', 'initial')
|
||||
|
||||
function Component() {
|
||||
useQuickReactor(
|
||||
'test-reactor',
|
||||
() => {
|
||||
mockEffectFn(externalAtom.get())
|
||||
},
|
||||
[]
|
||||
)
|
||||
return <div>test</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledWith('initial')
|
||||
|
||||
await act(() => {
|
||||
externalAtom.set('changed')
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith('changed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dependency array behavior', () => {
|
||||
it('recreates the reactor when dependencies change', async () => {
|
||||
const dep = 'dep1'
|
||||
let setDep: (newDep: string) => void
|
||||
const reactorExecutions: string[] = []
|
||||
|
||||
function Component() {
|
||||
const [currentDep, setCurrentDep] = useState(dep)
|
||||
setDep = setCurrentDep
|
||||
|
||||
useQuickReactor(
|
||||
'test-reactor',
|
||||
() => {
|
||||
reactorExecutions.push(`executed with ${currentDep}`)
|
||||
mockEffectFn(currentDep)
|
||||
},
|
||||
[currentDep]
|
||||
)
|
||||
|
||||
return <div>{currentDep}</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledWith('dep1')
|
||||
expect(reactorExecutions).toEqual(['executed with dep1'])
|
||||
|
||||
// Change dependency - should recreate reactor
|
||||
await act(() => {
|
||||
setDep('dep2')
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith('dep2')
|
||||
expect(reactorExecutions).toEqual(['executed with dep1', 'executed with dep2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup behavior', () => {
|
||||
it('cleans up the effect scheduler when component unmounts', async () => {
|
||||
let theAtom: Atom<number>
|
||||
let isUnmounted = false
|
||||
|
||||
function Component() {
|
||||
theAtom = useAtom('counter', 0)
|
||||
useQuickReactor(
|
||||
'test-reactor',
|
||||
() => {
|
||||
if (isUnmounted) {
|
||||
mockEffectFn('should-not-execute')
|
||||
} else {
|
||||
mockEffectFn(theAtom.get())
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
return <div>{theAtom.get()}</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledWith(0)
|
||||
|
||||
// Unmount the component
|
||||
await act(() => {
|
||||
view.unmount()
|
||||
isUnmounted = true
|
||||
})
|
||||
|
||||
// Try to change the atom after unmount - effect should not run
|
||||
await act(() => {
|
||||
theAtom!.set(10)
|
||||
})
|
||||
|
||||
// Should still only have been called once (during mount)
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockEffectFn).not.toHaveBeenCalledWith('should-not-execute')
|
||||
})
|
||||
|
||||
it('cleans up the previous scheduler when dependencies change', async () => {
|
||||
const dep = 1
|
||||
let setDep: (newDep: number) => void
|
||||
const oldAtom = atom('old', 'old-value')
|
||||
const newAtom = atom('new', 'new-value')
|
||||
|
||||
function Component() {
|
||||
const [currentDep, setCurrentDep] = useState(dep)
|
||||
setDep = setCurrentDep
|
||||
|
||||
useQuickReactor(
|
||||
'test-reactor',
|
||||
() => {
|
||||
const atomToUse = currentDep === 1 ? oldAtom : newAtom
|
||||
mockEffectFn(atomToUse.get())
|
||||
},
|
||||
[currentDep]
|
||||
)
|
||||
|
||||
return <div>dep: {currentDep}</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledWith('old-value')
|
||||
|
||||
// Change the old atom - should trigger effect
|
||||
await act(() => {
|
||||
oldAtom.set('old-updated')
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith('old-updated')
|
||||
|
||||
// Change dependency - should cleanup old scheduler and create new one
|
||||
await act(() => {
|
||||
setDep(2)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(3)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith('new-value')
|
||||
|
||||
// Old atom changes should no longer trigger effects
|
||||
await act(() => {
|
||||
oldAtom.set('old-should-not-trigger')
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(3) // No additional calls
|
||||
|
||||
// New atom changes should trigger effects
|
||||
await act(() => {
|
||||
newAtom.set('new-updated')
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(4)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith('new-updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple reactors', () => {
|
||||
it('supports multiple reactors in the same component', async () => {
|
||||
let atom1: Atom<number>
|
||||
let atom2: Atom<string>
|
||||
const effect1 = vi.fn()
|
||||
const effect2 = vi.fn()
|
||||
|
||||
function Component() {
|
||||
atom1 = useAtom('atom1', 1)
|
||||
atom2 = useAtom('atom2', 'a')
|
||||
|
||||
useQuickReactor(
|
||||
'reactor1',
|
||||
() => {
|
||||
effect1(atom1.get())
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useQuickReactor(
|
||||
'reactor2',
|
||||
() => {
|
||||
effect2(atom2.get())
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{atom1.get()} {atom2.get()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(effect1).toHaveBeenCalledWith(1)
|
||||
expect(effect2).toHaveBeenCalledWith('a')
|
||||
|
||||
// Change first atom
|
||||
await act(() => {
|
||||
atom1!.set(2)
|
||||
})
|
||||
|
||||
expect(effect1).toHaveBeenCalledTimes(2)
|
||||
expect(effect1).toHaveBeenLastCalledWith(2)
|
||||
expect(effect2).toHaveBeenCalledTimes(1) // Should not be affected
|
||||
|
||||
// Change second atom
|
||||
await act(() => {
|
||||
atom2!.set('b')
|
||||
})
|
||||
|
||||
expect(effect1).toHaveBeenCalledTimes(2) // Should not be affected
|
||||
expect(effect2).toHaveBeenCalledTimes(2)
|
||||
expect(effect2).toHaveBeenLastCalledWith('b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('handles complex dependency tracking with multiple atoms', async () => {
|
||||
let atom1: Atom<number>
|
||||
let atom2: Atom<number>
|
||||
let atom3: Atom<number>
|
||||
|
||||
function Component() {
|
||||
atom1 = useAtom('a', 1)
|
||||
atom2 = useAtom('b', 2)
|
||||
atom3 = useAtom('c', 3)
|
||||
|
||||
useQuickReactor(
|
||||
'complex-reactor',
|
||||
() => {
|
||||
const sum = atom1.get() + atom2.get() + atom3.get()
|
||||
mockEffectFn(sum)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return <div>sum</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledWith(6) // 1 + 2 + 3
|
||||
|
||||
// Change each atom individually
|
||||
await act(() => {
|
||||
atom1!.set(10)
|
||||
})
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith(15) // 10 + 2 + 3
|
||||
|
||||
await act(() => {
|
||||
atom2!.set(20)
|
||||
})
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith(33) // 10 + 20 + 3
|
||||
|
||||
await act(() => {
|
||||
atom3!.set(30)
|
||||
})
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith(60) // 10 + 20 + 30
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('works with conditional atom access', async () => {
|
||||
let toggleAtom: Atom<boolean>
|
||||
let atom1: Atom<string>
|
||||
let atom2: Atom<string>
|
||||
|
||||
function Component() {
|
||||
toggleAtom = useAtom('toggle', true)
|
||||
atom1 = useAtom('atom1', 'value1')
|
||||
atom2 = useAtom('atom2', 'value2')
|
||||
|
||||
useQuickReactor(
|
||||
'conditional-reactor',
|
||||
() => {
|
||||
const useFirst = toggleAtom.get()
|
||||
const value = useFirst ? atom1.get() : atom2.get()
|
||||
mockEffectFn(value)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return <div>conditional</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledWith('value1')
|
||||
|
||||
// Change atom1 - should trigger since toggle is true
|
||||
await act(() => {
|
||||
atom1!.set('new-value1')
|
||||
})
|
||||
expect(mockEffectFn).toHaveBeenCalledWith('new-value1')
|
||||
|
||||
// Change atom2 - should NOT trigger since toggle is true
|
||||
await act(() => {
|
||||
atom2!.set('new-value2')
|
||||
})
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(2) // No new call
|
||||
|
||||
// Toggle to false - should now use atom2
|
||||
await act(() => {
|
||||
toggleAtom!.set(false)
|
||||
})
|
||||
expect(mockEffectFn).toHaveBeenCalledWith('new-value2')
|
||||
|
||||
// Now changes to atom1 should not trigger
|
||||
await act(() => {
|
||||
atom1!.set('ignored-value1')
|
||||
})
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(3) // No new call
|
||||
|
||||
// But changes to atom2 should trigger
|
||||
await act(() => {
|
||||
atom2!.set('final-value2')
|
||||
})
|
||||
expect(mockEffectFn).toHaveBeenCalledWith('final-value2')
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
54
packages/state-react/src/lib/useQuickReactor.ts
Normal file
54
packages/state-react/src/lib/useQuickReactor.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { EMPTY_ARRAY, EffectScheduler } from '@tldraw/state'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* A React hook that runs side effects immediately in response to signal changes, without throttling.
|
||||
* Unlike useReactor which batches updates to animation frames, useQuickReactor executes the effect
|
||||
* function immediately when dependencies change, making it ideal for critical updates that cannot wait.
|
||||
*
|
||||
* The effect runs immediately when the component mounts and whenever tracked signals change.
|
||||
* Updates are not throttled, so the effect executes synchronously on every change.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* function DataSynchronizer() {
|
||||
* const criticalData = useAtom('criticalData', null)
|
||||
*
|
||||
* useQuickReactor('sync-data', () => {
|
||||
* const data = criticalData.get()
|
||||
* if (data) {
|
||||
* // Send immediately - don't wait for next frame
|
||||
* sendToServer(data)
|
||||
* }
|
||||
* }, [criticalData])
|
||||
*
|
||||
* return <div>Sync status updated</div>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* function CursorUpdater({ editor }) {
|
||||
* useQuickReactor('update-cursor', () => {
|
||||
* const cursor = editor.getInstanceState().cursor
|
||||
* document.body.style.cursor = cursor.type
|
||||
* }, [])
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param name - A descriptive name for the reactor, used for debugging and performance profiling
|
||||
* @param reactFn - The effect function to execute when signals change. Should not return a value.
|
||||
* @param deps - Optional dependency array that controls when the reactor is recreated. Works like useEffect deps.
|
||||
* @public
|
||||
*/
|
||||
export function useQuickReactor(name: string, reactFn: () => void, deps: any[] = EMPTY_ARRAY) {
|
||||
useEffect(() => {
|
||||
const scheduler = new EffectScheduler(name, reactFn)
|
||||
scheduler.attach()
|
||||
scheduler.execute()
|
||||
return () => {
|
||||
scheduler.detach()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
}
|
||||
128
packages/state-react/src/lib/useReactor.test.tsx
Normal file
128
packages/state-react/src/lib/useReactor.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { act, render, RenderResult } from '@testing-library/react'
|
||||
import { Atom } from '@tldraw/state'
|
||||
import { useState } from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useAtom } from './useAtom'
|
||||
import { useReactor } from './useReactor'
|
||||
|
||||
describe('useReactor', () => {
|
||||
let mockEffectFn: ReturnType<typeof vi.fn<(...args: any[]) => any>>
|
||||
let view: RenderResult
|
||||
|
||||
beforeEach(() => {
|
||||
mockEffectFn = vi.fn()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('executes the effect function immediately on mount', async () => {
|
||||
function Component() {
|
||||
useReactor('test-reactor', mockEffectFn, [])
|
||||
return <div>test</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('executes the effect function when tracked signals change', async () => {
|
||||
let theAtom: Atom<number>
|
||||
function Component() {
|
||||
theAtom = useAtom('counter', 0)
|
||||
useReactor(
|
||||
'test-reactor',
|
||||
() => {
|
||||
mockEffectFn(theAtom.get())
|
||||
},
|
||||
[]
|
||||
)
|
||||
return <div>{theAtom.get()}</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith(0)
|
||||
|
||||
// Change the atom value - should trigger throttled effect
|
||||
await act(() => {
|
||||
theAtom!.set(5)
|
||||
})
|
||||
|
||||
// In test mode, throttleToNextFrame executes immediately
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith(5)
|
||||
})
|
||||
|
||||
it('recreates the reactor when dependencies change', async () => {
|
||||
let setDep: (newDep: string) => void
|
||||
|
||||
function Component() {
|
||||
const [currentDep, setCurrentDep] = useState('dep1')
|
||||
setDep = setCurrentDep
|
||||
|
||||
useReactor(
|
||||
'test-reactor',
|
||||
() => {
|
||||
mockEffectFn(currentDep)
|
||||
},
|
||||
[currentDep]
|
||||
)
|
||||
|
||||
return <div>{currentDep}</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledWith('dep1')
|
||||
|
||||
// Change dependency - should recreate reactor
|
||||
await act(() => {
|
||||
setDep('dep2')
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockEffectFn).toHaveBeenLastCalledWith('dep2')
|
||||
})
|
||||
|
||||
it('cleans up effects when component unmounts', async () => {
|
||||
let theAtom: Atom<number>
|
||||
|
||||
function Component() {
|
||||
theAtom = useAtom('counter', 0)
|
||||
useReactor(
|
||||
'test-reactor',
|
||||
() => {
|
||||
mockEffectFn(theAtom.get())
|
||||
},
|
||||
[]
|
||||
)
|
||||
return <div>{theAtom.get()}</div>
|
||||
}
|
||||
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(mockEffectFn).toHaveBeenCalledWith(0)
|
||||
|
||||
// Unmount the component
|
||||
await act(() => {
|
||||
view.unmount()
|
||||
})
|
||||
|
||||
// Try to change the atom after unmount - effect should not run
|
||||
await act(() => {
|
||||
theAtom!.set(10)
|
||||
})
|
||||
|
||||
// Should still only have been called once (during mount)
|
||||
expect(mockEffectFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
93
packages/state-react/src/lib/useReactor.ts
Normal file
93
packages/state-react/src/lib/useReactor.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { EffectScheduler } from '@tldraw/state'
|
||||
import { throttleToNextFrame } from '@tldraw/utils'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* A React hook that runs a side effect in response to changes in signals (reactive state).
|
||||
*
|
||||
* The effect function will automatically track any signals (atoms, computed values) that are
|
||||
* accessed during its execution. When any of those signals change, the effect will be
|
||||
* scheduled to run again on the next animation frame.
|
||||
*
|
||||
* This is useful for performing side effects (like updating the DOM, making API calls, or
|
||||
* updating external state) in response to changes in tldraw's reactive state system, while
|
||||
* keeping those effects efficiently batched and throttled.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useReactor, useEditor } from 'tldraw'
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const editor = useEditor()
|
||||
*
|
||||
* // Update document title when shapes change
|
||||
* useReactor(
|
||||
* 'update title',
|
||||
* () => {
|
||||
* const shapes = editor.getCurrentPageShapes()
|
||||
* document.title = `Shapes: ${shapes.length}`
|
||||
* },
|
||||
* [editor]
|
||||
* )
|
||||
*
|
||||
* return <div>...</div>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useReactor, useEditor } from 'tldraw'
|
||||
*
|
||||
* function SelectionAnnouncer() {
|
||||
* const editor = useEditor()
|
||||
*
|
||||
* // Announce selection changes for accessibility
|
||||
* useReactor(
|
||||
* 'announce selection',
|
||||
* () => {
|
||||
* const selectedIds = editor.getSelectedShapeIds()
|
||||
* if (selectedIds.length > 0) {
|
||||
* console.log(`Selected ${selectedIds.length} shape(s)`)
|
||||
* }
|
||||
* },
|
||||
* [editor]
|
||||
* )
|
||||
*
|
||||
* return null
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* The effect is throttled to run at most once per animation frame using `requestAnimationFrame`.
|
||||
* This makes it suitable for effects that need to respond to state changes but don't need to
|
||||
* run synchronously.
|
||||
*
|
||||
* If you need the effect to run immediately without throttling, use {@link useQuickReactor} instead.
|
||||
*
|
||||
* The effect function will be re-created when any of the `deps` change, similar to React's
|
||||
* `useEffect`. The effect automatically tracks which signals it accesses, so you don't need
|
||||
* to manually specify them as dependencies.
|
||||
*
|
||||
* @param name - A debug name for the effect, useful for debugging and performance profiling.
|
||||
* @param reactFn - The effect function to run. Any signals accessed in this function will be tracked.
|
||||
* @param deps - React dependencies array. The effect will be recreated when these change. Defaults to `[]`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useReactor(name: string, reactFn: () => void, deps: undefined | any[] = []) {
|
||||
useEffect(() => {
|
||||
let cancelFn: (() => void) | undefined
|
||||
const scheduler = new EffectScheduler(name, reactFn, {
|
||||
scheduleEffect: (cb) => {
|
||||
cancelFn = throttleToNextFrame(cb)
|
||||
},
|
||||
})
|
||||
scheduler.attach()
|
||||
scheduler.execute()
|
||||
return () => {
|
||||
scheduler.detach()
|
||||
cancelFn?.()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
}
|
||||
158
packages/state-react/src/lib/useStateTracking.test.tsx
Normal file
158
packages/state-react/src/lib/useStateTracking.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { act, render, RenderResult } from '@testing-library/react'
|
||||
import { atom } from '@tldraw/state'
|
||||
import { sleep } from '@tldraw/utils'
|
||||
import * as React from 'react'
|
||||
import { useStateTracking } from './useStateTracking'
|
||||
|
||||
describe('useStateTracking', () => {
|
||||
it('causes a rerender when a dependency changes', async () => {
|
||||
const a = atom('', 0)
|
||||
|
||||
const Component = () => {
|
||||
const val = useStateTracking('', () => {
|
||||
return a.get()
|
||||
})
|
||||
return <>You are {val} years old</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"You are 0 years old"')
|
||||
|
||||
act(() => {
|
||||
a.set(1)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"You are 1 years old"')
|
||||
})
|
||||
|
||||
it('allows using hooks inside the callback', async () => {
|
||||
const _age = atom('', 0)
|
||||
let setHeight: (height: number) => void
|
||||
|
||||
const Component = () => {
|
||||
let height
|
||||
const age = useStateTracking('', () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
;[height, setHeight] = React.useState(20)
|
||||
return _age.get()
|
||||
})
|
||||
return (
|
||||
<>
|
||||
You are {age} years old and {height} meters tall
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(
|
||||
'"You are 0 years old and 20 meters tall"'
|
||||
)
|
||||
|
||||
act(() => {
|
||||
_age.set(1)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(
|
||||
'"You are 1 years old and 20 meters tall"'
|
||||
)
|
||||
|
||||
act(() => {
|
||||
setHeight(21)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(
|
||||
'"You are 1 years old and 21 meters tall"'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows throwing promises to trigger suspense boundaries', async () => {
|
||||
const a = atom<null | number>('age', null)
|
||||
|
||||
let resolve = (_val: string) => {
|
||||
// noop
|
||||
}
|
||||
|
||||
const Component = () => {
|
||||
const val = useStateTracking('', () => {
|
||||
if (a.get() === null) {
|
||||
throw new Promise<string>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
}
|
||||
return a.get()
|
||||
})
|
||||
return <>You are {val} years old</>
|
||||
}
|
||||
|
||||
let view: RenderResult = null as any
|
||||
await act(() => {
|
||||
view = render(
|
||||
<React.Suspense fallback={<>fallback</>}>
|
||||
<Component />
|
||||
</React.Suspense>
|
||||
)
|
||||
})
|
||||
|
||||
expect(view.asFragment().textContent).toMatchInlineSnapshot(`"fallback"`)
|
||||
|
||||
await act(() => {
|
||||
a.set(1)
|
||||
})
|
||||
// merely setting the value won't trigger a rerender, the promise must resolve
|
||||
expect(view.asFragment().textContent).toMatchInlineSnapshot(`"fallback"`)
|
||||
|
||||
await act(() => {
|
||||
resolve('resolved')
|
||||
})
|
||||
|
||||
await sleep(0)
|
||||
|
||||
expect(view.asFragment().textContent).toMatchInlineSnapshot('"You are 1 years old"')
|
||||
})
|
||||
|
||||
it('stops reacting when the component unmounts', async () => {
|
||||
const a = atom('', 0)
|
||||
let numRenders = 0
|
||||
const Component = () => {
|
||||
const val = useStateTracking('', () => {
|
||||
numRenders++
|
||||
return a.get()
|
||||
})
|
||||
return <>You are {val} years old</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(React.createElement(Component))
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"You are 0 years old"')
|
||||
|
||||
expect(numRenders).toBe(1)
|
||||
|
||||
await act(() => {
|
||||
a.set(1)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"You are 1 years old"')
|
||||
|
||||
expect(numRenders).toBe(2)
|
||||
|
||||
await act(() => {
|
||||
view!.unmount()
|
||||
})
|
||||
|
||||
await act(() => {
|
||||
a.set(2)
|
||||
})
|
||||
|
||||
expect(numRenders).toBe(2)
|
||||
})
|
||||
})
|
||||
85
packages/state-react/src/lib/useStateTracking.ts
Normal file
85
packages/state-react/src/lib/useStateTracking.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { EffectScheduler } from '@tldraw/state'
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Wraps some synchronous react render logic in a reactive tracking context.
|
||||
*
|
||||
* This allows you to use reactive values transparently.
|
||||
*
|
||||
* See the `track` component wrapper, which uses this under the hood.
|
||||
*
|
||||
* @param name - A debug name for the reactive tracking context
|
||||
* @param render - The render function that accesses reactive values
|
||||
* @param deps - Optional dependency array to control when the tracking context is recreated
|
||||
* @returns The result of calling the render function
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* function MyComponent() {
|
||||
* return useStateTracking('MyComponent', () => {
|
||||
* const editor = useEditor()
|
||||
* return <div>Num shapes: {editor.getCurrentPageShapes().length}</div>
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useStateTracking<T>(name: string, render: () => T, deps: unknown[] = []): T {
|
||||
// This hook creates an effect scheduler that will trigger re-renders when its reactive dependencies change, but it
|
||||
// defers the actual execution of the effect to the consumer of this hook.
|
||||
|
||||
// We need the exec fn to always be up-to-date when calling scheduler.execute() but it'd be wasteful to
|
||||
// instantiate a new EffectScheduler on every render, so we use an immediately-updated ref
|
||||
// to wrap it
|
||||
const renderRef = React.useRef(render)
|
||||
renderRef.current = render
|
||||
|
||||
const [scheduler, subscribe, getSnapshot] = React.useMemo(() => {
|
||||
let scheduleUpdate = null as null | (() => void)
|
||||
// useSyncExternalStore requires a subscribe function that returns an unsubscribe function
|
||||
const subscribe = (cb: () => void) => {
|
||||
scheduleUpdate = cb
|
||||
return () => {
|
||||
scheduleUpdate = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduler = new EffectScheduler(
|
||||
`useStateTracking(${name})`,
|
||||
// this is what `scheduler.execute()` will call
|
||||
() => renderRef.current(),
|
||||
// this is what will be invoked when @tldraw/state detects a change in an upstream reactive value
|
||||
{
|
||||
scheduleEffect() {
|
||||
scheduleUpdate?.()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// we use an incrementing number based on when this
|
||||
const getSnapshot = () => scheduler.scheduleCount
|
||||
|
||||
return [scheduler, subscribe, getSnapshot]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [name, ...deps])
|
||||
|
||||
React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
// reactive dependencies are captured when `scheduler.execute()` is called
|
||||
// and then to make it reactive we wait for a `useEffect` to 'attach'
|
||||
// this allows us to avoid rendering outside of React's render phase
|
||||
// and avoid 'zombie' components that try to render with bad/deleted data before
|
||||
// react has a chance to umount them.
|
||||
React.useEffect(() => {
|
||||
scheduler.attach()
|
||||
// do not execute, we only do that in render
|
||||
scheduler.maybeScheduleEffect()
|
||||
return () => {
|
||||
scheduler.detach()
|
||||
}
|
||||
}, [scheduler])
|
||||
|
||||
return scheduler.execute()
|
||||
}
|
||||
209
packages/state-react/src/lib/useValue.test.tsx
Normal file
209
packages/state-react/src/lib/useValue.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { RenderResult, act, render } from '@testing-library/react'
|
||||
import { Atom, Computed, atom } from '@tldraw/state'
|
||||
import { Component, ReactNode, useState } from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useAtom } from './useAtom'
|
||||
import { useComputed } from './useComputed'
|
||||
import { useValue } from './useValue'
|
||||
|
||||
// Error boundary component for testing
|
||||
class TestErrorBoundary extends Component<
|
||||
{ children: ReactNode; onError?(error: Error): void },
|
||||
{ hasError: boolean; error: Error | null }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; onError?(error: Error): void }) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error) {
|
||||
this.props.onError?.(error)
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.hasError) {
|
||||
return <div data-testid="error-boundary">Error: {this.state.error?.message}</div>
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
test('useValue returns a value from a computed', async () => {
|
||||
let theComputed = null as null | Computed<number>
|
||||
let theAtom = null as null | Atom<number>
|
||||
function Component() {
|
||||
const a = useAtom('a', 1)
|
||||
theAtom = a
|
||||
const b = useComputed('a+1', () => a.get() + 1, [])
|
||||
theComputed = b
|
||||
return <>{useValue(b)}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(theComputed).not.toBeNull()
|
||||
expect(theComputed?.get()).toBe(2)
|
||||
expect(theComputed?.name).toBe('useComputed(a+1)')
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"2"`)
|
||||
|
||||
await act(() => {
|
||||
theAtom?.set(5)
|
||||
})
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"6"`)
|
||||
})
|
||||
|
||||
test('useValue returns a value from an atom', async () => {
|
||||
let theAtom = null as null | Atom<number>
|
||||
function Component() {
|
||||
const a = useAtom('a', 1)
|
||||
theAtom = a
|
||||
return <>{useValue(a)}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"1"`)
|
||||
|
||||
await act(() => {
|
||||
theAtom?.set(5)
|
||||
})
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"5"`)
|
||||
})
|
||||
|
||||
test('useValue returns a value from a compute function', async () => {
|
||||
let theAtom = null as null | Atom<number>
|
||||
let setB = null as null | ((b: number) => void)
|
||||
function Component() {
|
||||
const a = useAtom('a', 1)
|
||||
const [b, _setB] = useState(1)
|
||||
setB = _setB
|
||||
theAtom = a
|
||||
const c = useValue('a+b', () => a.get() + b, [b])
|
||||
return <>{c}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
await act(() => {
|
||||
view = render(<Component />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"2"`)
|
||||
|
||||
await act(() => {
|
||||
theAtom?.set(5)
|
||||
})
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"6"`)
|
||||
|
||||
await act(() => {
|
||||
setB!(5)
|
||||
})
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot(`"10"`)
|
||||
})
|
||||
|
||||
test("useValue doesn't throw when used in a zombie-child component", async () => {
|
||||
const theAtom = atom<Record<string, number>>('map', { a: 1, b: 2, c: 3 })
|
||||
let numThrows = 0
|
||||
function Parent() {
|
||||
const ids = useValue('ids', () => Object.keys(theAtom.get()), [])
|
||||
return (
|
||||
<>
|
||||
{ids.map((id) => (
|
||||
<Child key={id} id={id} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
function Child({ id }: { id: string }) {
|
||||
const value = useValue(
|
||||
'value',
|
||||
() => {
|
||||
if (!(id in theAtom.get())) {
|
||||
numThrows++
|
||||
throw new Error('id not found!')
|
||||
}
|
||||
return theAtom.get()[id]
|
||||
},
|
||||
[id]
|
||||
)
|
||||
return <>{value}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
act(() => {
|
||||
view = render(<Parent />)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"123"')
|
||||
|
||||
expect(numThrows).toBe(0)
|
||||
// remove id 'b' creating a zombie-child
|
||||
act(() => {
|
||||
theAtom?.update(({ b: _, ...rest }) => rest)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"13"')
|
||||
|
||||
expect(numThrows).toBe(1)
|
||||
})
|
||||
|
||||
test('useValue throws synchronously during render when the computed throws', async () => {
|
||||
const theAtom = atom<Error | null>('map', null)
|
||||
let caughtError = null as null | Error
|
||||
|
||||
// Suppress React's console.error for this test
|
||||
|
||||
function Component({ id }: { id: string }) {
|
||||
const value = useValue(
|
||||
'value',
|
||||
() => {
|
||||
const error = theAtom.get()
|
||||
if (error) throw error
|
||||
return 1
|
||||
},
|
||||
[id]
|
||||
)
|
||||
return <>{value}</>
|
||||
}
|
||||
|
||||
let view: RenderResult
|
||||
act(() => {
|
||||
view = render(
|
||||
<TestErrorBoundary
|
||||
onError={(error) => {
|
||||
caughtError = error
|
||||
}}
|
||||
>
|
||||
<Component id="a" />
|
||||
</TestErrorBoundary>
|
||||
)
|
||||
})
|
||||
|
||||
expect(view!.asFragment().textContent).toMatchInlineSnapshot('"1"')
|
||||
|
||||
// ignore console.error here because react will log the error to console.error
|
||||
// even though it's caught by the error boundary
|
||||
const originalError = console.error
|
||||
console.error = vi.fn()
|
||||
try {
|
||||
act(() => {
|
||||
theAtom.set(new Error('test'))
|
||||
})
|
||||
} finally {
|
||||
console.error = originalError
|
||||
}
|
||||
|
||||
expect(caughtError).toBeInstanceOf(Error)
|
||||
expect(caughtError?.message).toBe('test')
|
||||
expect(view!.getByTestId('error-boundary')).toBeTruthy()
|
||||
expect(view!.getByTestId('error-boundary').textContent).toBe('Error: test')
|
||||
})
|
||||
108
packages/state-react/src/lib/useValue.ts
Normal file
108
packages/state-react/src/lib/useValue.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
import { Signal, computed, react } from '@tldraw/state'
|
||||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
/**
|
||||
* Extracts the current value from a signal and subscribes the component to changes.
|
||||
*
|
||||
* This is the most straightforward way to read signal values in React components.
|
||||
* When the signal changes, the component will automatically re-render with the new value.
|
||||
*
|
||||
* Note: You do not need to use this hook if you are wrapping the component with {@link track},
|
||||
* as tracked components automatically subscribe to any signals accessed with `.get()`.
|
||||
*
|
||||
* @param value - The signal to read the value from
|
||||
* @returns The current value of the signal
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { atom } from '@tldraw/state'
|
||||
* import { useValue } from '@tldraw/state-react'
|
||||
*
|
||||
* const count = atom('count', 0)
|
||||
*
|
||||
* function Counter() {
|
||||
* const currentCount = useValue(count)
|
||||
* return (
|
||||
* <button onClick={() => count.set(currentCount + 1)}>
|
||||
* Count: {currentCount}
|
||||
* </button>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useValue<Value>(value: Signal<Value>): Value
|
||||
|
||||
/**
|
||||
* Creates a computed value with automatic dependency tracking and subscribes to changes.
|
||||
*
|
||||
* This overload allows you to compute a value from one or more signals with automatic
|
||||
* memoization. The computed function will only re-execute when its dependencies change,
|
||||
* and the component will only re-render when the computed result changes.
|
||||
*
|
||||
* @param name - A descriptive name for debugging purposes
|
||||
* @param fn - Function that computes the value, should call `.get()` on any signals it depends on
|
||||
* @param deps - Array of signals that the computed function depends on
|
||||
* @returns The computed value
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { atom } from '@tldraw/state'
|
||||
* import { useValue } from '@tldraw/state-react'
|
||||
*
|
||||
* const firstName = atom('firstName', 'John')
|
||||
* const lastName = atom('lastName', 'Doe')
|
||||
*
|
||||
* function UserGreeting() {
|
||||
* const fullName = useValue('fullName', () => {
|
||||
* return `${firstName.get()} ${lastName.get()}`
|
||||
* }, [firstName, lastName])
|
||||
*
|
||||
* return <div>Hello {fullName}!</div>
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useValue<Value>(name: string, fn: () => Value, deps: unknown[]): Value
|
||||
|
||||
/**
|
||||
* Implementation function for useValue hook overloads.
|
||||
*
|
||||
* Handles both single signal subscription and computed value creation with dependency tracking.
|
||||
* Uses React's useSyncExternalStore for efficient subscription management and automatic cleanup.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useValue() {
|
||||
const args = arguments
|
||||
// deps will be either the computed or the deps array
|
||||
const deps = args.length === 3 ? args[2] : [args[0]]
|
||||
const name = args.length === 3 ? args[0] : `useValue(${args[0].name})`
|
||||
|
||||
const { $val, subscribe, getSnapshot } = useMemo(() => {
|
||||
const $val =
|
||||
args.length === 1 ? (args[0] as Signal<any>) : (computed(name, args[1]) as Signal<any>)
|
||||
|
||||
return {
|
||||
$val,
|
||||
subscribe: (notify: () => void) => {
|
||||
return react(`useValue(${name})`, () => {
|
||||
try {
|
||||
$val.get()
|
||||
} catch {
|
||||
// Will be rethrown during render if the component doesn't unmount first.
|
||||
}
|
||||
notify()
|
||||
})
|
||||
},
|
||||
getSnapshot: () => $val.lastChangedEpoch,
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
|
||||
useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
return $val.__unsafe__getWithoutCapture()
|
||||
}
|
||||
7
packages/state-react/tsconfig.json
Normal file
7
packages/state-react/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../internal/config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
|
||||
"references": [{ "path": "../state" }, { "path": "../utils" }]
|
||||
}
|
||||
6
packages/state-react/vitest.config.ts
Normal file
6
packages/state-react/vitest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../internal/config/vitest/node-preset'
|
||||
|
||||
// State-react package uses the standard node preset with no overrides
|
||||
export default mergeConfig(baseConfig, {})
|
||||
Reference in New Issue
Block a user