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
typescript
1import { signal } from "@preact/signals-react";
2
3// ✅ Simple signal for primitive values
4export const currentBoardId = signal<string | null>(null);
5
6// ✅ Simple signal for complex state
7export const postsSignal = signal<Post[]>([]);
Computed Signal: Derives value from other signals
typescript
1import { signal, computed } from "@preact/signals-react";
2
3export const postsSignal = signal<Post[]>([]);
4export const filterSignal = signal<PostFilter>("all");
5
6// ✅ Computed signal automatically updates
7export const filteredPosts = computed(() => {
8 const posts = postsSignal.value;
9 const filter = filterSignal.value;
10
11 if (filter === "all") return posts;
12 return posts.filter((p) => p.type === filter);
13});
State Organization by Domain
Separate Files for Each Domain:
typescript
1// lib/signal/postSignals.ts
2import { signal, computed } from "@preact/signals-react";
3
4// State
5export const postsSignal = signal<Post[]>([]);
6export const selectedPostId = signal<string | null>(null);
7
8// Computed values
9export const selectedPost = computed(() => {
10 const id = selectedPostId.value;
11 if (!id) return null;
12 return postsSignal.value.find((p) => p.id === id);
13});
14
15export const postsByType = computed(() => {
16 const posts = postsSignal.value;
17 return {
18 wentWell: posts.filter((p) => p.type === "went_well"),
19 toImprove: posts.filter((p) => p.type === "to_improve"),
20 actionItems: posts.filter((p) => p.type === "action_items"),
21 };
22});
23
24// Actions
25export const addPost = (post: Post) => {
26 postsSignal.value = [...postsSignal.value, post];
27};
28
29export const updatePost = (id: string, updates: Partial<Post>) => {
30 postsSignal.value = postsSignal.value.map((p) =>
31 p.id === id ? { ...p, ...updates } : p
32 );
33};
34
35export const deletePost = (id: string) => {
36 postsSignal.value = postsSignal.value.filter((p) => p.id !== id);
37};
Action Creator Pattern
Encapsulate State Updates:
typescript
1// lib/signal/boardSignals.ts
2import { signal } from "@preact/signals-react";
3
4export const boardsSignal = signal<Board[]>([]);
5export const loadingSignal = signal<boolean>(false);
6export const errorSignal = signal<string | null>(null);
7
8// ✅ Action creators for complex operations
9export const loadBoards = async () => {
10 loadingSignal.value = true;
11 errorSignal.value = null;
12
13 try {
14 const boards = await fetchBoards();
15 boardsSignal.value = boards;
16 } catch (error) {
17 errorSignal.value = "Failed to load boards";
18 console.error(error);
19 } finally {
20 loadingSignal.value = false;
21 }
22};
23
24export const createBoard = async (name: string) => {
25 try {
26 const newBoard = await createBoardAction(name);
27 // Optimistic update
28 boardsSignal.value = [...boardsSignal.value, newBoard];
29 return newBoard;
30 } catch (error) {
31 errorSignal.value = "Failed to create board";
32 throw error;
33 }
34};
Update Multiple Signals Together:
typescript
1import { batch } from "@preact/signals-react";
2
3// ❌ Bad: Triggers 3 re-renders
4const updateBoard = (id: string, data: BoardUpdate) => {
5 boardsSignal.value = updateBoardList(id, data);
6 selectedBoardId.value = id;
7 lastUpdatedSignal.value = Date.now();
8};
9
10// ✅ Good: Triggers 1 re-render
11const updateBoard = (id: string, data: BoardUpdate) => {
12 batch(() => {
13 boardsSignal.value = updateBoardList(id, data);
14 selectedBoardId.value = id;
15 lastUpdatedSignal.value = Date.now();
16 });
17};
Integration with React Components
Reading Signals:
typescript
1"use client";
2
3import { postsSignal, filteredPosts } from "@/lib/signal/postSignals";
4
5export function PostList() {
6 // ✅ Component re-renders when signal changes
7 const posts = filteredPosts.value;
8
9 return (
10 <div>
11 {posts.map((post) => (
12 <PostCard key={post.id} post={post} />
13 ))}
14 </div>
15 );
16}
Updating Signals:
typescript
1"use client";
2
3import { updatePost } from "@/lib/signal/postSignals";
4
5export function EditPostForm({ postId }: { postId: string }) {
6 const handleSubmit = (content: string) => {
7 // ✅ Update signal
8 updatePost(postId, { content });
9
10 // Persist to database
11 updatePostAction(postId, content);
12 };
13
14 return <form onSubmit={handleSubmit}>...</form>;
15}
Combining Signals with Server Actions
Pattern: Update signal first (optimistic), then persist
typescript
1"use client";
2
3import { addPost, deletePost } from "@/lib/signal/postSignals";
4import { createPost as createPostAction } from "@/lib/actions/post/createPost";
5
6export function CreatePostButton({ boardId }: { boardId: string }) {
7 const handleCreate = async () => {
8 // Create temporary post for optimistic UI
9 const tempPost: Post = {
10 id: `temp-${Date.now()}`,
11 boardId,
12 content: "",
13 type: "went_well",
14 createdAt: new Date(),
15 };
16
17 // ✅ Optimistic update
18 addPost(tempPost);
19
20 try {
21 // Persist to database
22 const savedPost = await createPostAction(boardId, "", "went_well");
23
24 // Replace temp with real post
25 deletePost(tempPost.id);
26 addPost(savedPost);
27 } catch (error) {
28 // Rollback on error
29 deletePost(tempPost.id);
30 showError("Failed to create post");
31 }
32 };
33
34 return <button onClick={handleCreate}>Create Post</button>;
35}
Avoid Unnecessary Signal Subscriptions:
typescript
1// ❌ Bad: Creates new computed signal on every render
2function PostCount() {
3 const count = computed(() => postsSignal.value.length);
4 return <div>{count.value}</div>;
5}
6
7// ✅ Good: Computed signal defined once outside component
8const postCount = computed(() => postsSignal.value.length);
9
10function PostCount() {
11 return <div>{postCount.value}</div>;
12}
Use Signal Peeking for Non-Reactive Reads:
typescript
1import { postsSignal } from "@/lib/signal/postSignals";
2
3function logCurrentPosts() {
4 // ✅ Read without subscribing (doesn't trigger re-render)
5 console.log("Current posts:", postsSignal.peek());
6}
Anti-Patterns
❌ Mutating Signal Values Directly
Bad:
typescript
1// ❌ Never mutate signal values directly
2postsSignal.value.push(newPost);
Good:
typescript
1// ✅ Create new array
2postsSignal.value = [...postsSignal.value, newPost];
❌ Creating Signals Inside Components
Bad:
typescript
1function MyComponent() {
2 // ❌ Creates new signal on every render
3 const localSignal = signal(0);
4 return <div>{localSignal.value}</div>;
5}
Good:
typescript
1// ✅ Define signals outside components
2const counterSignal = signal(0);
3
4function MyComponent() {
5 return <div>{counterSignal.value}</div>;
6}
❌ Not Using Batch for Multiple Updates
Bad:
typescript
1// ❌ Triggers 3 re-renders
2const resetFilters = () => {
3 filterSignal.value = "all";
4 sortSignal.value = "date";
5 searchSignal.value = "";
6};
Good:
typescript
1// ✅ Triggers 1 re-render
2import { batch } from "@preact/signals-react";
3
4const resetFilters = () => {
5 batch(() => {
6 filterSignal.value = "all";
7 sortSignal.value = "date";
8 searchSignal.value = "";
9 });
10};
❌ Forgetting .value Accessor
Bad:
typescript
1// ❌ Comparing signal object, not value
2if (currentBoardId === "board-123") {
3 // This will never be true
4}
Good:
typescript
1// ✅ Access signal value
2if (currentBoardId.value === "board-123") {
3 // Correct comparison
4}
Integration with Other Skills
Project-Specific Context
Key Files
lib/signal/boardSignals.ts - Board listing and management
lib/signal/postSignals.ts - Post state within boards
lib/signal/memberSignals.ts - Board member management
components/board/PostProvider.tsx - Signal updates from real-time
Domain-Specific Signals
Board Management:
typescript
1// lib/signal/boardSignals.ts
2export const boardsSignal = signal<Board[]>([]);
3export const currentBoardId = signal<string | null>(null);
4export const currentBoard = computed(() =>
5 boardsSignal.value.find((b) => b.id === currentBoardId.value)
6);
Post Management:
typescript
1// lib/signal/postSignals.ts
2export const postsSignal = signal<Post[]>([]);
3export const postFilter = signal<PostType | "all">("all");
4export const filteredPosts = computed(() => {
5 const filter = postFilter.value;
6 if (filter === "all") return postsSignal.value;
7 return postsSignal.value.filter((p) => p.type === filter);
8});
Member Management:
typescript
1// lib/signal/memberSignals.ts
2export const membersSignal = signal<Member[]>([]);
3export const currentUserRole = computed(() => {
4 const members = membersSignal.value;
5 const userId = currentUserId.value;
6 return members.find((m) => m.userId === userId)?.role || "guest";
7});
Real-Time Integration
Update Signals from Ably Messages:
typescript
1// components/board/PostProvider.tsx
2useChannel(`board:${boardId}`, (message) => {
3 switch (message.name) {
4 case "post:create":
5 addPost(message.data);
6 break;
7
8 case "post:update":
9 updatePost(message.data.id, message.data);
10 break;
11
12 case "post:delete":
13 deletePost(message.data.id);
14 break;
15 }
16});
Testing Signals
Reset Signals in beforeEach:
typescript
1import { postsSignal, filterSignal } from "@/lib/signal/postSignals";
2
3beforeEach(() => {
4 postsSignal.value = [];
5 filterSignal.value = "all";
6});
7
8test("filters posts by type", () => {
9 postsSignal.value = [
10 { id: "1", type: "went_well", content: "Test" },
11 { id: "2", type: "to_improve", content: "Test" },
12 ];
13
14 filterSignal.value = "went_well";
15 expect(filteredPosts.value).toHaveLength(1);
16});
Last Updated: 2026-01-10