Jovi De Croock

Software Engineer

Written on

Beyond Hooks: Why Signals and State Models

We've all been there, right? Managing state directly within our components using hooks like useState has become second nature for us (P)React developers. While this approach works for simple cases, it often leads to tightly coupled code that's difficult to test, reuse, and reason about. I want to explore a different paradigm using Preact Signals that promotes better separation of concerns through dedicated state models.

This approach can be seen in practice when re-watching my CodeTV Episode with the source code available on GitHub

The Problem with Hook-Centric State Management

Let's look at this typical React component using hooks:

const TodoList = () => {
  const [todos, setTodos] = useState([])
  const [filter, setFilter] = useState('all')

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

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

  const completedCount =
    todos.filter(t => t.completed).length

  return (
    // JSX with business logic mixed in
  )
}

While functional, this approach has several drawbacks:

  • Your component becomes a dumping ground for both presentation logic and business logic. Testing business rules requires rendering components, and reusing logic across different views becomes nearly impossible.
  • State is scattered across multiple useState declarations, making it harder to understand the complete state shape and manage complex state transitions.
  • No clear domain model exists - business concepts like "Todo" or "TodoList" don't exist as first-class entities in your code. Instead, they're just plain objects floating around in component state.
  • Performance implications are real - every state change triggers component re-renders, even when only specific parts of the UI need to update.

Enter Preact Signals: A Different Approach

Preact Signals offer a compelling alternative that promotes separation of concerns through dedicated state models. Let's reimagine our todo application:

Creating Individual Todo Models

import { computed, signal, type Signal } from '@preact/signals';

export type Todo = {
  id: number;
  text: Signal<string>;
  completed: Signal<boolean>;
  toggle: () => void;
};

export function createTodo(text: string): Todo {
  const textSignal = signal(text);
  const completed = signal(false);

  const toggle = () => {
    completed.value = !completed.value;
  };

  return {
    // Properties
    id: Date.now(),
    get text() {
      return textSignal.value;
    },
    get completed() {
      return completed.value;
    },
    // Actions
    toggle,
    set text(value: string) {
      textSignal.value = value;
    },
  };
}

Here, each Todo is a self-contained model with its own state and behavior. The text and completed properties are signals that can be observed and updated independently. This means that we only re-render the parts that are subscribed to these properties rather than everything that uses the TodoList component, as we'd regularly see with hooks.

Building Composite State Models

export function createTodoList() {
  const list = signal<Todo[]>([]);

  const add = (todoText: string) => {
    list.value = [...list.value, createTodo(todoText)];
  };

  const remove = (id: number) => {
    list.value = list.value.filter(todo => todo.id !== id);
  };

  const completed = computed(() =>
    list.value.every((todo) => todo.completed.value),
  );
  const count = computed(() => list.value.length);
  const completedCount = computed(() =>
    completed.value.length,
  );

  return {
    // Views
    get items() {
      return list.value;
    },
    get count() {
      return count.value;
    },
    get completedItems() {
      return completed.value;
    },
    get completedCount() {
      return completedCount.value;
    },
    // Actions
    add,
    remove,
  };
}

The createTodoList function returns a state model that encapsulates all todo-related operations. Notice how completedItems is a computed signal that automatically updates when any todo's completion status changes.

We can either create a singleton instance of this model or pass one through context, you can consider a signal a boxed value, we only subscribe a component once .value is accessed, and it will re-compute when the value changes.

The Benefits in Action

Clean Separation of Concerns

Your components become purely presentational:

import { TodoListContext } from './models/todoList';

const TodoList = () => {
  const todoList = useContext(TodoListContext);
  return (
    <div>
      <h2>Todos ({todoList.count})</h2>
      <ul>
        {todoList.items.map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
      <AddTodoForm />
    </div>
  );
};

const TodoItem = ({ todo }) => (
  <li>
    <input
      type="checkbox"
      checked={todo.completed}
      onChange={todo.toggle}
    />
    <span>{todo.text}</span>
  </li>
);

Effortless Testing

Business logic testing becomes straightforward since models are independent of React:

import { describe, it, expect } from 'vitest';
import { createTodo, createTodoList } from './todoModels';

describe('Todo Model', () => {
  it('should toggle completion status', () => {
    const todo = createTodo('Learn Signals');
    expect(todo.completed).toBe(false);

    todo.toggle();
    expect(todo.completed).toBe(true);
  });
});

describe('TodoList Model', () => {
  it('should track completion status', () => {
    const todos = createTodoList();
    todos.add('Task 1');
    todos.add('Task 2');

    expect(todos.completedCount).toBe(0);

    todos.items.forEach(todo => todo.toggle());
    expect(todos.completedCount).toBe(2);
  });
});

Granular Reactivity

Signals provide surgical updates. When a single todo's text changes, only that specific component re-renders:

const TodoItem = ({ todo }) => {
  // Only re-renders when THIS todo's signals change
  return (
    <li className={todo.completed ? 'completed' : ''}>
      <input
        type="text"
        value={todo.text}
        onChange={
          (e) => todo.text = e.target.value
        }
      />
    </li>
  );
};

Stronger than that, when we use preact we could even use the boxed variant of our signal and it would directly update the DOM-attribute or text-node. We can also further optimize the granularity of our updates by using the signal-utils like Show and For.

Reusable Across Different Views

Want to display todos in a different format? Just create a new component that uses the same model:

const TodoSummary = () => (
  <div>
    <p>Total: {todoList.count}</p>
    <p>Completed: {todoList.completedCount}</p>
    <progress
      value={todoList.completedCount}
      max={todoList.count}
    />
  </div>
);

Conclusion

While React hooks revolutionized state management by making it more functional and composable, Preact Signals take this evolution further by promoting true separation of concerns. By moving state and business logic into dedicated models, we achieve:

  • Better testability through framework-independent business logic
  • Improved reusability across different views and components
  • Enhanced performance via granular reactivity
  • Clearer architecture with explicit domain models
  • Easier debugging with predictable state changes

The next time you find yourself writing const [state, setState] = useState(), consider whether that state belongs in a dedicated model instead. Your future self (and your teammates) will thank you for the cleaner, more maintainable codebase.


Ready to try Preact Signals in your next project? Check out the official documentation and start building.