Front-end Engineering Lab

Signals

Ultra-fast reactivity with automatic dependency tracking

Signals are a reactive primitive that automatically track dependencies and update only what's necessary—no Virtual DOM diffing, no unnecessary re-renders. Used by Solid.js, Preact, and Angular.

The Core Concept

// Traditional React
const [count, setCount] = useState(0);
// Component re-renders on every setState

// Signals
const count = signal(0);
// Only subscribers update, no re-render!

Using @preact/signals-react

npm install @preact/signals-react

Basic Usage

// signals/counter.ts
import { signal } from '@preact/signals-react';

export const count = signal(0);

export function increment() {
  count.value++;
}

export function decrement() {
  count.value--;
}
// components/Counter.tsx
import { count, increment, decrement } from '@/signals/counter';

export function Counter() {
  // No useState, no re-renders!
  // Component renders once, signal updates DOM directly
  
  return (
    <div>
      <h2>Count: {count.value}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  );
}

Computed Signals (Derived State)

// signals/counter.ts
import { signal, computed } from '@preact/signals-react';

export const count = signal(0);

// Automatically recomputes when count changes
export const doubleCount = computed(() => count.value * 2);

export const isEven = computed(() => count.value % 2 === 0);
export function CounterDisplay() {
  return (
    <div>
      <p>Count: {count.value}</p>
      <p>Double: {doubleCount.value}</p>
      <p>Is Even: {isEven.value ? 'Yes' : 'No'}</p>
    </div>
  );
}

Effects (Side Effects)

// signals/user.ts
import { signal, effect } from '@preact/signals-react';

export const theme = signal<'light' | 'dark'>('light');

// Run when theme changes
effect(() => {
  document.documentElement.classList.toggle('dark', theme.value === 'dark');
  localStorage.setItem('theme', theme.value);
});

// Auto-cleanup on unmount

Batch Updates

import { batch, signal } from '@preact/signals-react';

const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

// Without batch: fullName computes twice
firstName.value = 'Jane';
lastName.value = 'Smith';

// With batch: fullName computes once
batch(() => {
  firstName.value = 'Jane';
  lastName.value = 'Smith';
});

Complex State Management

// signals/todos.ts
import { signal, computed } from '@preact/signals-react';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

export const todos = signal<Todo[]>([]);
export const filter = signal<'all' | 'active' | 'completed'>('all');

export const filteredTodos = computed(() => {
  const currentTodos = todos.value;
  const currentFilter = filter.value;
  
  switch (currentFilter) {
    case 'active':
      return currentTodos.filter(t => !t.completed);
    case 'completed':
      return currentTodos.filter(t => t.completed);
    default:
      return currentTodos;
  }
});

export const todoStats = computed(() => {
  const currentTodos = todos.value;
  return {
    total: currentTodos.length,
    active: currentTodos.filter(t => !t.completed).length,
    completed: currentTodos.filter(t => t.completed).length,
  };
});

// Actions
export function addTodo(text: string) {
  todos.value = [
    ...todos.value,
    {
      id: crypto.randomUUID(),
      text,
      completed: false,
    },
  ];
}

export function toggleTodo(id: string) {
  todos.value = todos.value.map(todo =>
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  );
}

export function deleteTodo(id: string) {
  todos.value = todos.value.filter(todo => todo.id !== id);
}
// components/TodoApp.tsx
import {
  todos,
  filter,
  filteredTodos,
  todoStats,
  addTodo,
  toggleTodo,
  deleteTodo,
} from '@/signals/todos';

export function TodoApp() {
  const [input, setInput] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) {
      addTodo(input);
      setInput('');
    }
  };

  return (
    <div>
      <h1>Todos</h1>
      
      {/* Stats - only updates when todoStats changes */}
      <div>
        Total: {todoStats.value.total} |
        Active: {todoStats.value.active} |
        Completed: {todoStats.value.completed}
      </div>
      
      {/* Filter */}
      <div>
        <button onClick={() => filter.value = 'all'}>All</button>
        <button onClick={() => filter.value = 'active'}>Active</button>
        <button onClick={() => filter.value = 'completed'}>Completed</button>
      </div>
      
      {/* Add Todo */}
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="What needs to be done?"
        />
        <button type="submit">Add</button>
      </form>
      
      {/* Todo List - only updates when filteredTodos changes */}
      <ul>
        {filteredTodos.value.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Async Data with Signals

// signals/user.ts
import { signal, computed } from '@preact/signals-react';

interface User {
  id: string;
  name: string;
  email: string;
}

export const userId = signal('123');
export const userLoading = signal(false);
export const userError = signal<string | null>(null);
export const userData = signal<User | null>(null);

// Fetch user when userId changes
effect(async () => {
  const id = userId.value;
  
  userLoading.value = true;
  userError.value = null;
  
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error('Failed to fetch user');
    
    userData.value = await response.json();
  } catch (error) {
    userError.value = error instanceof Error ? error.message : 'Unknown error';
  } finally {
    userLoading.value = false;
  }
});
// components/UserProfile.tsx
import { userData, userLoading, userError } from '@/signals/user';

export function UserProfile() {
  if (userLoading.value) return <div>Loading...</div>;
  if (userError.value) return <div>Error: {userError.value}</div>;
  if (!userData.value) return <div>No user</div>;

  return (
    <div>
      <h2>{userData.value.name}</h2>
      <p>{userData.value.email}</p>
    </div>
  );
}

Performance Comparison

// React (re-renders entire component)
function ReactCounter() {
  const [count, setCount] = useState(0);
  
  console.log('Component rendered'); // Logs on every setState
  
  return <div>{count}</div>;
}

// Signals (no re-render, updates DOM directly)
const count = signal(0);

function SignalsCounter() {
  console.log('Component rendered'); // Logs once!
  
  return <div>{count.value}</div>; // Updates without re-render
}

Integration with React State

// Mix signals with React state when needed
export function MixedComponent() {
  // Local UI state (React)
  const [isOpen, setIsOpen] = useState(false);
  
  // Global state (Signals)
  const count = useSignal(0);
  
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        Toggle (React State)
      </button>
      
      <button onClick={() => count.value++}>
        Increment (Signal)
      </button>
      
      {isOpen && <div>Count: {count.value}</div>}
    </div>
  );
}

Debugging

// Log signal changes
effect(() => {
  console.log('Count changed:', count.value);
});

// Peek without subscribing
console.log(count.peek()); // Doesn't create subscription

Best Practices

  1. Use for global state: Signals excel at shared state
  2. Keep local state in React: Simple UI state
  3. Computed for derived: Don't duplicate calculations
  4. Batch updates: Multiple changes at once
  5. Effects for side effects: DOM updates, localStorage
  6. Peek for debugging: Avoid accidental subscriptions
  7. Small signals: Fine-grained reactivity
  8. Type signals: Full TypeScript support
  9. Avoid in loops: Use arrays of items
  10. Test independently: Signals work outside React

When to Use Signals

Use signals when:

  • Performance is critical (gaming, real-time)
  • Lots of rapid updates
  • Fine-grained subscriptions needed
  • Want minimal bundle size
  • Avoiding Virtual DOM overhead

Don't use signals when:

  • Team unfamiliar with reactive programming
  • Need React-specific features (Suspense)
  • Simple apps (Context is enough)
  • Heavy React ecosystem dependencies

Common Pitfalls

Forgetting .value: Accessing signal object
Always use .value to read/write

Creating signals in render: Memory leak
Define signals outside components

Not using computed: Recalculating manually
Use computed() for derived values

Too many effects: Performance hit
Batch related logic in one effect

Mutating signal directly: signal.value.push()
Replace entire value for arrays/objects

Signals provide the fastest reactivity possible—perfect for performance-critical applications with frequent updates!

On this page