.claude/skills/signal-state-management/SKILL.md
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
npx skillsauth add DW225/ree-board signal-state-managementInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Activate this skill when working on:
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);
});
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);
};
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;
}
};
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();
});
};
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>;
}
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>;
}
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());
}
Bad:
// ❌ Never mutate signal values directly
postsSignal.value.push(newPost);
Good:
// ✅ Create new array
postsSignal.value = [...postsSignal.value, newPost];
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>;
}
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 = "";
});
};
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
}
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-timeBoard 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";
});
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;
}
});
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
development
Jest testing strategies, test organization, factory patterns for test data, mocking strategies for authentication and external services, real-time message processor testing, test-driven development workflow, unit vs integration testing, fake timer usage for time-dependent tests, and testing best practices for ree-board project
testing
Role-based access control (RBAC) patterns, authentication wrappers, authorization checks, input validation with Zod schemas, security boundaries, server action security, real-time message validation, preventing common vulnerabilities like XSS and SQL injection, and security best practices for ree-board project
tools
Next.js 16 App Router patterns including server components, client components, server actions, route handlers, layouts, metadata API, dynamic routes, file conventions, data fetching, caching strategies, and Next.js best practices for building modern React applications
data-ai
Drizzle ORM best practices including schema design with relationships, database migrations, prepared statements for performance, transactions, indexes, Turso SQLite database operations, type safety patterns, query optimization, and database workflow for ree-board project