Skip to content
All essays
WebMarch 27, 202615 min

SolidJS Reactivity: Mastering Signals, Effects, and Reactive Primitives

Deep dive into SolidJS reactivity system. Learn about signals, effects, memos, and reactive primitives that power fine-grained updates.

Ü
Ümit Uz
Mobile & Full Stack Developer

SolidJS's reactivity system is its crown jewel, enabling fine-grained updates without the performance overhead of Virtual DOM reconciliation. This comprehensive guide explores the reactive primitives that make SolidJS incredibly fast and efficient.

Understanding SolidJS Reactivity

SolidJS uses a reactive programming model where computations automatically track their dependencies and update when those dependencies change. Unlike React's manual dependency management, SolidJS automatically tracks what data each computation depends on.

Key Concepts

  • Automatic Dependency Tracking: No manual dependency arrays
  • Fine-Grained Updates: Only update what changes
  • No Virtual DOM: Direct DOM manipulation
  • Push-based Reactivity: Changes propagate immediately

Signals: The Foundation of Reactivity

Signals are reactive state containers that notify dependents when their value changes.

Creating Signals

tsx
import { createSignal } from 'solid-js';

const [count, setCount] = createSignal(0);

// Read signal
console.log(count()); // 0

// Update signal
setCount(1);
console.log(count()); // 1

// Update based on previous value
setCount(c => c + 1);
console.log(count()); // 2

Signal Best Practices

  1. 1Use Descriptive Names: Clear signal purposes
  2. 2Type Signals Properly: Leverage TypeScript
  3. 3Keep Signals Focused: Single responsibility
tsx
interface User {
  id: number;
  name: string;
  email: string;
}

function UserProfile() {
  const [user, setUser] = createSignal<User | null>(null);
  const [isLoading, setIsLoading] = createSignal(false);
  const [error, setError] = createSignal<string | null>(null);

  // Type-safe signal usage
  const loadUser = async (id: number) => {
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(`/api/users/${id}`);
      const userData = await response.json();
      setUser(userData);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <Show when={isLoading()}>
        <p>Loading...</p>
      </Show>
      <Show when={error()}>
        <p>Error: {error()}</p>
      </Show>
      <Show when={user()}>
        <h1>{user()!.name}</h1>
        <p>{user()!.email}</p>
      </Show>
    </div>
  );
}

Computed Signals

Derive values from other signals automatically:

tsx
function ShoppingCart() {
  const [items, setItems] = createSignal<CartItem[]>([]);
  const [taxRate, setTaxRate] = createSignal(0.08);

  // Computed values
  const subtotal = () => items().reduce((sum, item) => sum + item.price * item.quantity, 0);
  const tax = () => subtotal() * taxRate();
  const total = () => subtotal() + tax();

  return (
    <div>
      <p>Subtotal: ${subtotal()}</p>
      <p>Tax: ${tax()}</p>
      <p>Total: ${total()}</p>
    </div>
  );
}

Effects: Reacting to Changes

Effects run code in response to signal changes. They're perfect for side effects like DOM manipulation, API calls, or subscriptions.

Basic Effects

tsx
import { createEffect } from 'solid-js';

function DocumentTitle() {
  const [title, setTitle] = createSignal('Home');

  createEffect(() => {
    document.title = title();
  });

  return <input value={title()} onInput={(e) => setTitle(e.currentTarget.value)} />;
}

Effect Dependencies

Effects automatically track their dependencies:

tsx
function UserData() {
  const [userId, setUserId] = createSignal(1);
  const [user, setUser] = createSignal<User | null>(null);

  // This effect automatically tracks userId()
  createEffect(async () => {
    const id = userId();
    const response = await fetch(`/api/users/${id}`);
    const userData = await response.json();
    setUser(userData);
  });

  return (
    <div>
      <select onInput={(e) => setUserId(parseInt(e.currentTarget.value))}>
        <option value="1">User 1</option>
        <option value="2">User 2</option>
      </select>
      <Show when={user()}>
        <h1>{user()!.name}</h1>
      </Show>
    </div>
  );
}

Effect Cleanup

Return a cleanup function to run before the effect re-runs:

tsx
function WebSocketComponent() {
  const [messages, setMessages] = createSignal<Message[]>([]);
  const [isConnected, setIsConnected] = createSignal(false);

  createEffect(() => {
    if (!isConnected()) return;

    const ws = new WebSocket('ws://localhost:8080');

    ws.onmessage = (event) => {
      setMessages((msgs) => [...msgs, JSON.parse(event.data)]);
    };

    // Cleanup function
    return () => {
      ws.close();
    };
  });

  return (
    <div>
      <button onClick={() => setIsConnected(!isConnected())}>
        {isConnected() ? 'Disconnect' : 'Connect'}
      </button>
      <ul>
        <For each={messages()}>
          {(msg) => <li>{msg.text}</li>}
        </For>
      </ul>
    </div>
  );
}

Multiple Dependencies

Effects can track multiple signals:

tsx
function RectangleArea() {
  const [width, setWidth] = createSignal(10);
  const [height, setHeight] = createSignal(5);
  const [area, setArea] = createSignal(0);

  createEffect(() => {
    // Tracks both width() and height()
    setArea(width() * height());
  });

  return (
    <div>
      <input type="number" value={width()} onInput={(e) => setWidth(parseFloat(e.currentTarget.value))} />
      <input type="number" value={height()} onInput={(e) => setHeight(parseFloat(e.currentTarget.value))} />
      <p>Area: {area()}</p>
    </div>
  );
}

Memos: Cached Computed Values

Memos cache computed values and only recalculate when dependencies change:

tsx
import { createMemo } from 'solid-js';

function ExpensiveList() {
  const [items, setItems] = createSignal<number[]>([1, 2, 3, 4, 5]);
  const [filter, setFilter] = createSignal('');

  // Memoized filtered list
  const filteredItems = createMemo(() => {
    console.log('Filtering items...');
    return items().filter(item => item.toString().includes(filter()));
  });

  // Memoized sum
  const sum = createMemo(() => {
    console.log('Calculating sum...');
    return filteredItems().reduce((a, b) => a + b, 0);
  });

  return (
    <div>
      <input
        value={filter()}
        onInput={(e) => setFilter(e.currentTarget.value)}
        placeholder="Filter items"
      />
      <p>Filtered: {filteredItems().join(', ')}</p>
      <p>Sum: {sum()}</p>
    </div>
  );
}

When to Use Memos

  1. 1Expensive Computations: Filter, map, reduce large arrays
  2. 2Derived State: Computations based on multiple signals
  3. 3Performance Optimization: Avoid redundant calculations
tsx
function ProductList() {
  const [products, setProducts] = createSignal<Product[]>([]);
  const [searchTerm, setSearchTerm] = createSignal('');
  const [category, setCategory] = createSignal<string>('all');

  // Memoized filtered products
  const filteredProducts = createMemo(() => {
    return products().filter(product => {
      const matchesSearch = product.name.toLowerCase().includes(searchTerm().toLowerCase());
      const matchesCategory = category === 'all' || product.category === category;
      return matchesSearch && matchesCategory;
    });
  });

  // Memoized statistics
  const stats = createMemo(() => {
    const products = filteredProducts();
    return {
      total: products.length,
      avgPrice: products.reduce((sum, p) => sum + p.price, 0) / products.length || 0,
      maxPrice: Math.max(...products.map(p => p.price), 0),
    };
  });

  return (
    <div>
      <input value={searchTerm()} onInput={(e) => setSearchTerm(e.currentTarget.value)} />
      <select onInput={(e) => setCategory(e.currentTarget.value)}>
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>

      <div>
        <p>Total: {stats().total}</p>
        <p>Average Price: ${stats().avgPrice.toFixed(2)}</p>
      </div>

      <For each={filteredProducts()}>
        {(product) => (
          <div>
            <h3>{product.name}</h3>
            <p>${product.price}</p>
          </div>
        )}
      </For>
    </div>
  );
}

Reactive Utilities

SolidJS provides several utilities for working with reactivity.

createRoot

Create an isolated reactive context:

tsx
import { createRoot, createSignal } from 'solid-js';

createRoot((dispose) => {
  const [count, setCount] = createSignal(0);

  // All signals and effects here are isolated
  createEffect(() => {
    console.log('Count:', count());
  });

  // Call dispose() to clean up
});

onMount

Run code after component mounts:

tsx
import { onMount } from 'solid-js';

function DataComponent() {
  const [data, setData] = createSignal(null);

  onMount(async () => {
    const response = await fetch('/api/data');
    const json = await response.json();
    setData(json);
  });

  return <div>{/* render data */}</div>;
}

onCleanup

Run cleanup code when component unmounts:

tsx
import { onCleanup } from 'solid-js';

function TimerComponent() {
  const [seconds, setSeconds] = createSignal(0);

  const timer = setInterval(() => {
    setSeconds(s => s + 1);
  }, 1000);

  onCleanup(() => {
    clearInterval(timer);
  });

  return <div>Seconds: {seconds()}</div>;
}

catchError

Handle errors in effects:

tsx
import { catchError } from 'solid-js';

createEffect(() => {
  catchError(() => {
    // Code that might throw
    const result = riskyOperation(data());
    setData(result);
  }, (error) => {
    console.error('Error:', error);
    setError(error.message);
  });
});

on

Run effects only when specific dependencies change:

tsx
import { on } from 'solid-js';

function UserProfile() {
  const [userId, setUserId] = createSignal(1);
  const [user, setUser] = createSignal<User | null>(null);

  // Only re-run when userId changes
  createEffect(on(userId, async (id) => {
    const response = await fetch(`/api/users/${id}`);
    const userData = await response.json();
    setUser(userData);
  }));

  return <div>{/* render user */}</div>;
}

batch

Group multiple updates together:

tsx
import { batch } from 'solid-js';

function FormComponent() {
  const [firstName, setFirstName] = createSignal('');
  const [lastName, setLastName] = createSignal('');
  const [fullName, setFullName] = createSignal('');

  const updateName = () => {
    batch(() => {
      setFirstName('John');
      setLastName('Doe');
      setFullName('John Doe');
    });
    // Only one update happens here
  };

  return <div>{/* render form */}</div>;
}

Advanced Reactivity Patterns

Reactive Collections

tsx
function TodoList() {
  const [todos, setTodos] = createSignal<Todo[]>([]);

  // Reactive operations
  const addTodo = (text: string) => {
    setTodos((todos) => [...todos, { id: Date.now(), text, completed: false }]);
  };

  const toggleTodo = (id: number) => {
    setTodos((todos) =>
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const removeTodo = (id: number) => {
    setTodos((todos) => todos.filter((todo) => todo.id !== id));
  };

  // Reactive computed values
  const activeTodos = () => todos().filter((todo) => !todo.completed);
  const completedTodos = () => todos().filter((todo) => todo.completed);

  return (
    <div>
      <For each={todos()}>
        {(todo) => (
          <div>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span>{todo.text}</span>
            <button onClick={() => removeTodo(todo.id)}>Remove</button>
          </div>
        )}
      </For>
    </div>
  );
}

Reactive Forms

tsx
function createForm<T>(initialValues: T) {
  const [values, setValues] = createSignal<T>(initialValues);
  const [errors, setErrors] = createSignal<Record<keyof T, string>>({} as any);
  const [touched, setTouched] = createSignal<Record<keyof T, boolean>>({} as any);

  const setValue = (field: keyof T, value: T[keyof T]) => {
    setValues((values) => ({ ...values, [field]: value }));
  };

  const setFieldTouched = (field: keyof T) => {
    setTouched((touched) => ({ ...touched, [field]: true }));
  };

  const setFieldError = (field: keyof T, error: string) => {
    setErrors((errors) => ({ ...errors, [field]: error }));
  };

  const reset = () => {
    setValues(initialValues);
    setErrors({} as any);
    setTouched({} as any);
  };

  return {
    get values() { return values(); },
    get errors() { return errors(); },
    get touched() { return touched(); },
    setValue,
    setFieldTouched,
    setFieldError,
    reset,
  };
}

Reactive Async Operations

tsx
function useAsync<T>(asyncFn: () => Promise<T>) {
  const [data, setData] = createSignal<T | null>(null);
  const [error, setError] = createSignal<Error | null>(null);
  const [isLoading, setIsLoading] = createSignal(false);

  const execute = async () => {
    setIsLoading(true);
    setError(null);

    try {
      const result = await asyncFn();
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    } finally {
      setIsLoading(false);
    }
  };

  return {
    get data() { return data(); },
    get error() { return error(); },
    get isLoading() { return isLoading(); },
    execute,
  };
}

Performance Best Practices

  1. 1Use Memos for Expensive Computations: Cache derived values
  2. 2Batch Updates: Group multiple state changes
  3. 3Avoid Inline Functions: Define handlers outside render
  4. 4Use For for Lists: Efficient list rendering
  5. 5Leverage Fine-Grained Updates: Let SolidJS optimize updates

Common Pitfalls

  1. 1Forgetting to Call Signals: Remember to call signal functions
  2. 2Mutating State Directly: Always use setters
  3. 3Creating Signals in Effects: Create signals in component scope
  4. 4Ignoring Cleanup: Clean up subscriptions and timers

Conclusion

SolidJS's reactivity system provides a powerful, efficient way to manage state and side effects. By understanding signals, effects, memos, and reactive utilities, you can build applications that are both performant and maintainable. The automatic dependency tracking eliminates the need for manual dependency management, while fine-grained updates ensure optimal performance.

Master these reactive primitives, and you'll be able to create complex, reactive applications with confidence and efficiency.

Enjoyed this essay? Share it.

Related essays

Next essay
SolidJS Routing: Building Navigation with Solid Router