Agent skill
signal-state-management
Preact Signals for reactive state management, signal vs computed signal usage, batch updates for performance, action creator patterns, signal integration with React components, state management by domain (boards posts members), reactive patterns, and signal best practices for ree-board project
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/signal-state-management
SKILL.md
Signal State Management
When to Use This Skill
Activate this skill when working on:
- Managing client-side application state
- Creating reactive UI updates
- Implementing computed values
- Batching state updates for performance
- Organizing state by domain (boards, posts, members)
- Integrating signals with React components
- Optimizing re-renders
Core Patterns
Signal vs Computed Signal
Simple Signal: Holds mutable state
import { signal } from "@preact/signals-react";
// ✅ Simple signal for primitive values
export const currentBoardId = signal<string | null>(null);
// ✅ Simple signal for complex state
export const postsSignal = signal<Post[]>([]);
Computed Signal: Derives value from other signals
import { signal, computed } from "@preact/signals-react";
export const postsSignal = signal<Post[]>([]);
export const filterSignal = signal<PostFilter>("all");
// ✅ Computed signal automatically updates
export const filteredPosts = computed(() => {
const posts = postsSignal.value;
const filter = filterSignal.value;
if (filter === "all") return posts;
return posts.filter((p) => p.type === filter);
});
State Organization by Domain
Separate Files for Each Domain:
// lib/signal/postSignals.ts
import { signal, computed } from "@preact/signals-react";
// State
export const postsSignal = signal<Post[]>([]);
export const selectedPostId = signal<string | null>(null);
// Computed values
export const selectedPost = computed(() => {
const id = selectedPostId.value;
if (!id) return null;
return postsSignal.value.find((p) => p.id === id);
});
export const postsByType = computed(() => {
const posts = postsSignal.value;
return {
wentWell: posts.filter((p) => p.type === "went_well"),
toImprove: posts.filter((p) => p.type === "to_improve"),
actionItems: posts.filter((p) => p.type === "action_items"),
};
});
// Actions
export const addPost = (post: Post) => {
postsSignal.value = [...postsSignal.value, post];
};
export const updatePost = (id: string, updates: Partial<Post>) => {
postsSignal.value = postsSignal.value.map((p) =>
p.id === id ? { ...p, ...updates } : p
);
};
export const deletePost = (id: string) => {
postsSignal.value = postsSignal.value.filter((p) => p.id !== id);
};
Action Creator Pattern
Encapsulate State Updates:
// lib/signal/boardSignals.ts
import { signal } from "@preact/signals-react";
export const boardsSignal = signal<Board[]>([]);
export const loadingSignal = signal<boolean>(false);
export const errorSignal = signal<string | null>(null);
// ✅ Action creators for complex operations
export const loadBoards = async () => {
loadingSignal.value = true;
errorSignal.value = null;
try {
const boards = await fetchBoards();
boardsSignal.value = boards;
} catch (error) {
errorSignal.value = "Failed to load boards";
console.error(error);
} finally {
loadingSignal.value = false;
}
};
export const createBoard = async (name: string) => {
try {
const newBoard = await createBoardAction(name);
// Optimistic update
boardsSignal.value = [...boardsSignal.value, newBoard];
return newBoard;
} catch (error) {
errorSignal.value = "Failed to create board";
throw error;
}
};
Batch Updates for Performance
Update Multiple Signals Together:
import { batch } from "@preact/signals-react";
// ❌ Bad: Triggers 3 re-renders
const updateBoard = (id: string, data: BoardUpdate) => {
boardsSignal.value = updateBoardList(id, data);
selectedBoardId.value = id;
lastUpdatedSignal.value = Date.now();
};
// ✅ Good: Triggers 1 re-render
const updateBoard = (id: string, data: BoardUpdate) => {
batch(() => {
boardsSignal.value = updateBoardList(id, data);
selectedBoardId.value = id;
lastUpdatedSignal.value = Date.now();
});
};
Integration with React Components
Reading Signals:
"use client";
import { postsSignal, filteredPosts } from "@/lib/signal/postSignals";
export function PostList() {
// ✅ Component re-renders when signal changes
const posts = filteredPosts.value;
return (
<div>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Updating Signals:
"use client";
import { updatePost } from "@/lib/signal/postSignals";
export function EditPostForm({ postId }: { postId: string }) {
const handleSubmit = (content: string) => {
// ✅ Update signal
updatePost(postId, { content });
// Persist to database
updatePostAction(postId, content);
};
return <form onSubmit={handleSubmit}>...</form>;
}
Combining Signals with Server Actions
Pattern: Update signal first (optimistic), then persist
"use client";
import { addPost, deletePost } from "@/lib/signal/postSignals";
import { createPost as createPostAction } from "@/lib/actions/post/createPost";
export function CreatePostButton({ boardId }: { boardId: string }) {
const handleCreate = async () => {
// Create temporary post for optimistic UI
const tempPost: Post = {
id: `temp-${Date.now()}`,
boardId,
content: "",
type: "went_well",
createdAt: new Date(),
};
// ✅ Optimistic update
addPost(tempPost);
try {
// Persist to database
const savedPost = await createPostAction(boardId, "", "went_well");
// Replace temp with real post
deletePost(tempPost.id);
addPost(savedPost);
} catch (error) {
// Rollback on error
deletePost(tempPost.id);
showError("Failed to create post");
}
};
return <button onClick={handleCreate}>Create Post</button>;
}
Signal Performance Patterns
Avoid Unnecessary Signal Subscriptions:
// ❌ Bad: Creates new computed signal on every render
function PostCount() {
const count = computed(() => postsSignal.value.length);
return <div>{count.value}</div>;
}
// ✅ Good: Computed signal defined once outside component
const postCount = computed(() => postsSignal.value.length);
function PostCount() {
return <div>{postCount.value}</div>;
}
Use Signal Peeking for Non-Reactive Reads:
import { postsSignal } from "@/lib/signal/postSignals";
function logCurrentPosts() {
// ✅ Read without subscribing (doesn't trigger re-render)
console.log("Current posts:", postsSignal.peek());
}
Anti-Patterns
❌ Mutating Signal Values Directly
Bad:
// ❌ Never mutate signal values directly
postsSignal.value.push(newPost);
Good:
// ✅ Create new array
postsSignal.value = [...postsSignal.value, newPost];
❌ Creating Signals Inside Components
Bad:
function MyComponent() {
// ❌ Creates new signal on every render
const localSignal = signal(0);
return <div>{localSignal.value}</div>;
}
Good:
// ✅ Define signals outside components
const counterSignal = signal(0);
function MyComponent() {
return <div>{counterSignal.value}</div>;
}
❌ Not Using Batch for Multiple Updates
Bad:
// ❌ Triggers 3 re-renders
const resetFilters = () => {
filterSignal.value = "all";
sortSignal.value = "date";
searchSignal.value = "";
};
Good:
// ✅ Triggers 1 re-render
import { batch } from "@preact/signals-react";
const resetFilters = () => {
batch(() => {
filterSignal.value = "all";
sortSignal.value = "date";
searchSignal.value = "";
});
};
❌ Forgetting .value Accessor
Bad:
// ❌ Comparing signal object, not value
if (currentBoardId === "board-123") {
// This will never be true
}
Good:
// ✅ Access signal value
if (currentBoardId.value === "board-123") {
// Correct comparison
}
Integration with Other Skills
- nextjs-app-router: Signals used in client components
- ably-realtime: Update signals from real-time messages
- drizzle-patterns: Sync signals with database state
- testing-patterns: Reset signals in test setup
Project-Specific Context
Key Files
lib/signal/boardSignals.ts- Board listing and managementlib/signal/postSignals.ts- Post state within boardslib/signal/memberSignals.ts- Board member managementcomponents/board/PostProvider.tsx- Signal updates from real-time
Domain-Specific Signals
Board Management:
// lib/signal/boardSignals.ts
export const boardsSignal = signal<Board[]>([]);
export const currentBoardId = signal<string | null>(null);
export const currentBoard = computed(() =>
boardsSignal.value.find((b) => b.id === currentBoardId.value)
);
Post Management:
// lib/signal/postSignals.ts
export const postsSignal = signal<Post[]>([]);
export const postFilter = signal<PostType | "all">("all");
export const filteredPosts = computed(() => {
const filter = postFilter.value;
if (filter === "all") return postsSignal.value;
return postsSignal.value.filter((p) => p.type === filter);
});
Member Management:
// lib/signal/memberSignals.ts
export const membersSignal = signal<Member[]>([]);
export const currentUserRole = computed(() => {
const members = membersSignal.value;
const userId = currentUserId.value;
return members.find((m) => m.userId === userId)?.role || "guest";
});
Real-Time Integration
Update Signals from Ably Messages:
// components/board/PostProvider.tsx
useChannel(`board:${boardId}`, (message) => {
switch (message.name) {
case "post:create":
addPost(message.data);
break;
case "post:update":
updatePost(message.data.id, message.data);
break;
case "post:delete":
deletePost(message.data.id);
break;
}
});
Testing Signals
Reset Signals in beforeEach:
import { postsSignal, filterSignal } from "@/lib/signal/postSignals";
beforeEach(() => {
postsSignal.value = [];
filterSignal.value = "all";
});
test("filters posts by type", () => {
postsSignal.value = [
{ id: "1", type: "went_well", content: "Test" },
{ id: "2", type: "to_improve", content: "Test" },
];
filterSignal.value = "went_well";
expect(filteredPosts.value).toHaveLength(1);
});
Last Updated: 2026-01-10
Didn't find tool you were looking for?