State Synchronization
Status: ✅ Fully Implemented Version: 1.0 Last Updated: October 29, 2025
Overview
Minimact uses a dehydrationist architecture where the server is the source of truth for all component rendering. The client cannot render JSX or evaluate React expressions - it can only apply pre-computed patches from the server. This means client state must stay in sync with server state to prevent stale data issues.
This document explains how state synchronization works and why it's critical.
The Stale Data Problem
Without State Synchronization ❌
// CLIENT
const [isOpen, setIsOpen] = useState(false);
// User clicks button
setIsOpen(true); // ✅ Client state = true
// ✅ Applies cached patch (menu appears)
// ❌ Server NOT notified!
// SERVER (still has stale data)
isOpen = false; // ❌ Server doesn't know about the change!
// Next render from ANY other state change:
protected override VNode Render() {
return new VNode("div", null,
isOpen && new VNode("menu", null, "...") // Evaluates to false!
);
}
// 🔴 Rust reconciler sees:
// Old VNode: has menu (from client patch)
// New VNode: no menu (server evaluated isOpen=false)
// 🔴 Generates patch to REMOVE menu
// 🔴 User's menu disappears unexpectedly!With State Synchronization ✅
// CLIENT
const [isOpen, setIsOpen] = useState(false);
// User clicks button
setIsOpen(true); // ✅ Client state = true
// ✅ Applies cached patch (menu appears)
// ✅ Syncs to server via SignalR
// SERVER (receives sync message)
isOpen = true; // ✅ Server state updated!
// Next render from any other state change:
protected override VNode Render() {
return new VNode("div", null,
isOpen && new VNode("menu", null, "...") // Evaluates to true!
);
}
// 🟢 Rust reconciler sees:
// Old VNode: has menu
// New VNode: has menu
// 🟢 No patches needed
// 🟢 Menu stays visible!Why Client Can't Render
Minimact's dehydrationist architecture means:
- Server evaluates JSX - Only the server can run
{condition && <Component />} - Client applies patches - Client receives DOM operations from server
- No client-side JSX evaluation - Client doesn't have React reconciler
This is fundamentally different from hydration-based frameworks (Next.js, Remix, etc.) where the client can re-render components independently.
Implementation
Client-Side: useState Hook
File: src/client-runtime/src/hooks.ts
export function useState<T>(initialValue: T): [T, (newValue: T | ((prev: T) => T)) => void] {
if (!currentContext) {
throw new Error('useState must be called within a component render');
}
const context = currentContext;
const index = stateIndex++;
const stateKey = `state_${index}`;
// Initialize state if not exists
if (!context.state.has(stateKey)) {
context.state.set(stateKey, initialValue);
}
const setState = (newValue: T | ((prev: T) => T)) => {
const startTime = performance.now();
// Compute new value
const actualNewValue = typeof newValue === 'function'
? (newValue as (prev: T) => T)(context.state.get(stateKey) as T)
: newValue;
// 1. Update local state
context.state.set(stateKey, actualNewValue);
// 2. Build state change object
const stateChanges: Record<string, any> = {
[stateKey]: actualNewValue
};
// 3. Check hint queue (for instant feedback)
const hint = context.hintQueue.matchHint(context.componentId, stateChanges);
if (hint) {
// 🟢 CACHE HIT! Apply patches instantly
const latency = performance.now() - startTime;
console.log(`[Minimact] 🟢 CACHE HIT! ${latency.toFixed(2)}ms`);
context.domPatcher.applyPatches(context.element, hint.patches);
if (context.playgroundBridge) {
context.playgroundBridge.cacheHit({
componentId: context.componentId,
hintId: hint.hintId,
latency,
confidence: hint.confidence,
patchCount: hint.patches.length
});
}
} else {
// 🔴 CACHE MISS
console.log(`[Minimact] 🔴 CACHE MISS`);
if (context.playgroundBridge) {
context.playgroundBridge.cacheMiss({
componentId: context.componentId,
methodName: `setState(${stateKey})`,
latency: performance.now() - startTime,
patchCount: 0
});
}
}
// 4. ✅ SYNC TO SERVER (THE CRITICAL FIX!)
context.signalR.updateComponentState(context.componentId, stateKey, actualNewValue)
.catch(err => {
console.error('[Minimact] Failed to sync state to server:', err);
// TODO: Retry logic, offline queue, etc.
});
};
return [context.state.get(stateKey) as T, setState];
}Key Points:
- Step 1: Update local state (for hint queue matching)
- Step 2: Check hint queue and apply cached patches (instant feedback)
- Step 3: Sync to server via SignalR (prevent stale data)
Client-Side: useDomElementState Hook
File: src/minimact-punch/src/integration.ts
export function useDomElementState(selector?: string, options?: DomElementStateOptions): DomElementState {
if (!currentContext) {
throw new Error('[minimact-punch] useDomElementState must be called within a component render');
}
const context = currentContext;
const index = domElementStateIndex++;
const stateKey = `domElementState_${index}`;
if (!context.domElementStates) {
context.domElementStates = new Map();
}
if (!context.domElementStates.has(stateKey)) {
const domState = new DomElementState(selector, options);
// Set up change callback
domState.setOnChange((snapshot: DomElementStateSnapshot) => {
const startTime = performance.now();
// Build state change object
const stateChanges: Record<string, any> = {
[stateKey]: {
isIntersecting: snapshot.isIntersecting,
childrenCount: snapshot.childrenCount,
grandChildrenCount: snapshot.grandChildrenCount,
attributes: snapshot.attributes,
classList: snapshot.classList,
exists: snapshot.exists,
count: snapshot.count
}
};
// Check hint queue
const hint = context.hintQueue.matchHint(context.componentId, stateChanges);
if (hint) {
// 🟢 CACHE HIT
const latency = performance.now() - startTime;
context.domPatcher.applyPatches(context.element, hint.patches);
if (context.playgroundBridge) {
context.playgroundBridge.cacheHit({
componentId: context.componentId,
hintId: hint.hintId,
latency,
confidence: hint.confidence,
patchCount: hint.patches.length
});
}
} else {
// 🔴 CACHE MISS
if (context.playgroundBridge) {
context.playgroundBridge.cacheMiss({
componentId: context.componentId,
methodName: `domChange(${stateKey})`,
latency: performance.now() - startTime,
patchCount: 0
});
}
}
// ✅ SYNC TO SERVER (THE CRITICAL FIX!)
context.signalR.updateDomElementState(context.componentId, stateKey, {
isIntersecting: snapshot.isIntersecting,
intersectionRatio: snapshot.intersectionRatio,
childrenCount: snapshot.childrenCount,
grandChildrenCount: snapshot.grandChildrenCount,
attributes: snapshot.attributes,
classList: snapshot.classList,
exists: snapshot.exists,
count: snapshot.count
}).catch(err => {
console.error('[minimact-punch] Failed to sync DOM state to server:', err);
});
});
context.domElementStates.set(stateKey, domState);
}
return context.domElementStates.get(stateKey)!;
}Client-Side: SignalRManager
File: src/client-runtime/src/signalr-manager.ts
export class SignalRManager {
private connection: HubConnection;
/**
* Update component state on the server (from useState hook)
* This keeps server state in sync with client state changes
*/
async updateComponentState(componentId: string, stateKey: string, value: any): Promise<void> {
try {
await this.connection.invoke('UpdateComponentState', componentId, stateKey, value);
this.log('Updated component state', { componentId, stateKey, value });
} catch (error) {
console.error('[Minimact] Failed to update component state:', error);
throw error;
}
}
/**
* Update DOM element state on the server (from useDomElementState hook)
* This keeps server aware of DOM changes for accurate rendering
*/
async updateDomElementState(componentId: string, stateKey: string, snapshot: any): Promise<void> {
try {
await this.connection.invoke('UpdateDomElementState', componentId, stateKey, snapshot);
this.log('Updated DOM element state', { componentId, stateKey, snapshot });
} catch (error) {
console.error('[Minimact] Failed to update DOM element state:', error);
throw error;
}
}
}Server-Side: MinimactHub
File: src/Minimact.AspNetCore/SignalR/MinimactHub.cs
/// <summary>
/// Snapshot of DOM element state from useDomElementState hook
/// </summary>
public class DomElementStateSnapshot
{
public bool IsIntersecting { get; set; }
public double IntersectionRatio { get; set; }
public int ChildrenCount { get; set; }
public int GrandChildrenCount { get; set; }
public Dictionary<string, string> Attributes { get; set; } = new();
public List<string> ClassList { get; set; } = new();
public bool Exists { get; set; }
public int Count { get; set; }
}
public class MinimactHub : Hub
{
private readonly ComponentRegistry _registry;
/// <summary>
/// Update component state from client useState hook
/// Keeps server state in sync with client to prevent stale data
/// </summary>
public async Task UpdateComponentState(string componentId, string stateKey, object value)
{
var component = _registry.GetComponent(componentId);
if (component == null)
{
await Clients.Caller.SendAsync("Error", $"Component {componentId} not found");
return;
}
try
{
// Update the component's state from client
component.SetStateFromClient(stateKey, value);
// Trigger a re-render with the updated state
component.TriggerRender();
// Note: Client already applied cached patches for instant feedback
// This render ensures server state is correct for subsequent renders
}
catch (Exception ex)
{
await Clients.Caller.SendAsync("Error", $"Error updating component state: {ex.Message}");
}
}
/// <summary>
/// Update DOM element state from client useDomElementState hook
/// Keeps server aware of DOM changes for accurate rendering
/// </summary>
public async Task UpdateDomElementState(string componentId, string stateKey, DomElementStateSnapshot snapshot)
{
var component = _registry.GetComponent(componentId);
if (component == null)
{
await Clients.Caller.SendAsync("Error", $"Component {componentId} not found");
return;
}
try
{
// Update the component's DOM state from client
component.SetDomStateFromClient(stateKey, snapshot);
// Trigger a re-render with the updated DOM state
component.TriggerRender();
// Note: Client already applied cached patches for instant feedback
// This render ensures server state is correct for subsequent renders
}
catch (error) {
await Clients.Caller.SendAsync("Error", $"Error updating DOM element state: {ex.Message}");
}
}
}Server-Side: MinimactComponent
File: src/Minimact.AspNetCore/Core/MinimactComponent.cs
public abstract class MinimactComponent
{
protected Dictionary<string, object> State { get; } = new();
protected Dictionary<string, object> PreviousState { get; } = new();
/// <summary>
/// Set state from client-side useState hook
/// Keeps server state in sync with client to prevent stale data
/// </summary>
public void SetStateFromClient(string key, object value)
{
// Store previous value for diff
if (State.ContainsKey(key))
{
PreviousState[key] = State[key];
}
// Update state dictionary
State[key] = value;
// Sync state back to fields (if there's a corresponding [State] field)
StateManager.SyncStateToMembers(this);
// Note: We don't call TriggerRender() here because the client already
// applied cached patches. We just need to keep state in sync so the
// next render (from other causes) has correct data.
}
/// <summary>
/// Set DOM element state from client-side useDomElementState hook
/// Keeps server aware of DOM changes for accurate rendering
/// </summary>
public void SetDomStateFromClient(string key, DomElementStateSnapshot snapshot)
{
// Store DOM state in the State dictionary
// This allows components to access DOM state in their Render() method
if (State.ContainsKey(key))
{
PreviousState[key] = State[key];
}
State[key] = snapshot;
// Note: We don't call TriggerRender() here because the client already
// applied cached patches. We just need to keep state in sync so the
// next render (from other causes) has correct data.
}
}The Complete Flow
User Action → State Update → Sync
1. User clicks button
↓
2. Client: useState setter called
↓
3. Client: Update local state (for hint matching)
↓
4. Client: Check hint queue
↓
5a. IF cache hit → Apply patches instantly (0-2ms)
5b. IF cache miss → Wait for server render
↓
6. Client: Sync to server via SignalR.updateComponentState()
↓
7. Server: MinimactHub.UpdateComponentState() receives message
↓
8. Server: component.SetStateFromClient() updates state
↓
9. Server: component.TriggerRender() triggers re-render
↓
10. Server: Render() evaluates with NEW state
↓
11. Server: Rust reconciler computes patches
↓
12. Server: Sends patches to client (verification/correction)
↓
13. Client: Applies patches if different from predictionTimeline:
- 0ms: User clicks
- 0-2ms: Client applies cached patch (instant feedback!)
- 2ms: SignalR sync message sent (async)
- 10-50ms: Server receives, updates state, re-renders
- 50-100ms: Server sends verification patches (usually no-op if prediction was correct)
Why This Matters
Scenario: Dropdown Menu
function Header() {
const [isOpen, setIsOpen] = useState(false);
return (
<header>
<button onClick={() => setIsOpen(!isOpen)}>Menu</button>
{isOpen && <Menu />}
</header>
);
}Without sync:
- User clicks "Menu" →
isOpen = true(client only) - Menu appears (from cached patch)
- User types in search box → triggers unrelated state change
- Server re-renders → evaluates
isOpen = false(stale!) - Rust reconciler removes menu
- 🔴 Menu disappears unexpectedly!
With sync:
- User clicks "Menu" →
isOpen = true(client + sync to server) - Menu appears (from cached patch)
- Server receives sync →
isOpen = true(updated!) - User types in search box → triggers unrelated state change
- Server re-renders → evaluates
isOpen = true(correct!) - Rust reconciler sees no change to menu
- 🟢 Menu stays visible!
Performance Impact
Sync Overhead
Network:
- Single SignalR message per state change
- ~1-5ms latency (WebSocket)
- Asynchronous (doesn't block UI)
Server:
SetStateFromClient(): O(1) dictionary update- No re-render triggered (happens on next relevant change)
- Negligible CPU impact
Overall:
- User perception: 0ms (cache hit applies instantly)
- Background sync: 1-5ms (async)
- No noticeable overhead
Error Handling
Network Failure
If SignalR sync fails:
context.signalR.updateComponentState(componentId, stateKey, actualNewValue)
.catch(err => {
console.error('[Minimact] Failed to sync state to server:', err);
// TODO: Retry logic, offline queue, etc.
});Current behavior:
- Error logged to console
- Client state remains updated (cached patches applied)
- Server state remains stale
Future enhancement:
- Retry with exponential backoff
- Offline queue (sync when connection restored)
- User notification for critical failures
Best Practices
1. Always Use useState for Reactive State
// ✅ GOOD - Uses useState, syncs automatically
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
// ❌ BAD - Direct state mutation, no sync
let count = 0;
const increment = () => { count++; };2. Don't Bypass setState
// ✅ GOOD - Uses setState, triggers sync
setUser({ ...user, name: "Alice" });
// ❌ BAD - Direct mutation, no sync
user.name = "Alice";3. Use useDomElementState for DOM Observations
// ✅ GOOD - useDomElementState syncs automatically
const headerState = useDomElementState('.header');
useEffect(() => {
if (headerState.isIntersecting) {
// Sticky header logic
}
}, [headerState.isIntersecting]);
// ❌ BAD - Manual IntersectionObserver, no sync
useEffect(() => {
const observer = new IntersectionObserver(entries => {
// Changes not synced to server!
});
}, []);Future Enhancements
1. Batch Syncing
Currently, each setState triggers a separate SignalR message. Future enhancement:
// Batch multiple state changes into single message
batchStateUpdates(() => {
setName("Alice");
setAge(25);
setEmail("alice@example.com");
});
// → Single SignalR message with all changes2. Optimistic Updates with Rollback
If server rejects state change:
setCount(count + 1); // Apply optimistically
// Server validation fails → Roll back to previous value3. Offline Support
Queue state changes when offline:
// Offline
setCount(5); // Queued
setName("Bob"); // Queued
// Back online → Flush queue
// Server receives both updates4. Conflict Resolution
Handle concurrent edits from multiple clients:
// Client A: setCount(5)
// Client B: setCount(7)
// Server: Resolves conflict (last-write-wins, CRDT, etc.)Comparison to Other Frameworks
Next.js / Remix (Hydration)
// Client CAN re-render independently
const [count, setCount] = useState(0);
setCount(count + 1);
// → Client re-renders component
// → No server sync needed (until form submission, etc.)Difference: Client has full React reconciler, can evaluate JSX
Minimact (Dehydrationist)
// Client CANNOT re-render independently
const [count, setCount] = useState(0);
setCount(count + 1);
// → Client applies cached patch
// → MUST sync to server (server is source of truth for renders)Difference: Client has no JSX evaluator, server must know state
