โ† Back to DevBytes

State Management in SolidJS: Patterns and Libraries

Introduction to State Management in SolidJS

SolidJS has gained significant traction among frontend developers for its fine-grained reactivity system and uncompromising performance. Unlike React, which re-renders entire components when state changes, SolidJS tracks individual signals and updates only the specific DOM nodes that depend on them. This architectural difference fundamentally changes how we approach state management. In this tutorial, we'll explore the built-in primitives SolidJS provides, common patterns for organizing state, and the libraries that extend SolidJS for larger applications.

Why State Management Matters in SolidJS

State management is the backbone of any interactive application. It determines how data flows through your app, how UI updates in response to user actions, and how different parts of your application communicate. In SolidJS, effective state management matters because:

Core Primitives: Signals, Stores, and Memos

Signals

A signal is the most basic unit of reactive state in SolidJS. It holds a value and notifies subscribers when that value changes. You create a signal using the createSignal function, which returns a getter and a setter tuple.

import { createSignal } from "solid-js";

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

  return (
    <div>
      <p>Count: {count()}</p>
      <button onClick={() => setCount(count() + 1)}>Increment</button>
      <button onClick={() => setCount(c => c - 1)}>Decrement</button>
    </div>
  );
}

Notice that count is a function, not a value. This is intentional โ€” calling count() registers the current reactive scope as a subscriber, enabling fine-grained updates. The setter can accept either a new value or a function that receives the previous value.

Stores

While signals work great for primitives, managing complex nested objects with signals becomes cumbersome. SolidJS provides createStore for deeply reactive state objects. Stores use proxies under the hood to track property access and mutations at any depth.

import { createStore } from "solid-js/store";

function UserProfile() {
  const [user, setUser] = createStore({
    name: "Alice",
    age: 30,
    address: {
      city: "Portland",
      zip: "97201"
    },
    hobbies: ["reading", "hiking"]
  });

  const updateName = (newName) => {
    setUser("name", newName);
  };

  const updateCity = (newCity) => {
    setUser("address", "city", newCity);
  };

  const addHobby = (hobby) => {
    setUser("hobbies", (prev) => [...prev, hobby]);
  };

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>City: {user.address.city}</p>
      <p>Hobbies: {user.hobbies.join(", ")}</p>
      <button onClick={() => updateName("Bob")}>Change Name</button>
      <button onClick={() => addHobby("cooking")}>Add Hobby</button>
    </div>
  );
}

The setUser function accepts a path of keys followed by the new value. This path-based update system ensures that only the specific nested property triggers reactivity, leaving unrelated parts of the store untouched.

Memos

createMemo creates a derived value that is cached and only recomputes when its dependencies change. Memos are essential for expensive computations and for sharing derived state across multiple consumers.

import { createSignal, createMemo } from "solid-js";

function ShoppingCart() {
  const [items, setItems] = createSignal([
    { name: "Laptop", price: 999, qty: 1 },
    { name: "Mouse", price: 29, qty: 2 }
  ]);

  const subtotal = createMemo(() =>
    items().reduce((sum, item) => sum + item.price * item.qty, 0)
  );

  const tax = createMemo(() => subtotal() * 0.08);
  const total = createMemo(() => subtotal() + tax());

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

Sharing State Across Components

Context API

For state that needs to be accessible to deeply nested components without prop drilling, SolidJS provides a Context API similar to React's but with reactive semantics. Context values can include signals, stores, and functions.

import { createContext, useContext, createSignal, ParentComponent } from "solid-js";

type ThemeContextType = {
  theme: () => string;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextType>();

export const ThemeProvider: ParentComponent = (props) => {
  const [theme, setTheme] = createSignal("light");

  const toggleTheme = () =>
    setTheme((t) => (t === "light" ? "dark" : "light"));

  const value = { theme, toggleTheme };

  return (
    <ThemeContext.Provider value={value}>
      {props.children}
    </ThemeContext.Provider>
  );
};

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

// Usage in a component
function ThemedButton() {
  const { theme, toggleTheme } = useTheme();
  return (
    <button
      style={{
        background: theme() === "light" ? "#fff" : "#333",
        color: theme() === "light" ? "#333" : "#fff"
      }}
      onClick={toggleTheme}
    >
      Toggle Theme (Current: {theme()})
    </button>
  );
}

Global Singleton Stores

For truly global state that spans the entire application, you can create a singleton store outside of any component. This pattern is simple, effective, and works well for authentication, feature flags, and app-wide configuration.

import { createStore } from "solid-js/store";

// authStore.ts
const [auth, setAuth] = createStore({
  user: null as { id: string; name: string; email: string } | null,
  token: null as string | null,
  isAuthenticated: false
});

export function login(user: any, token: string) {
  setAuth({ user, token, isAuthenticated: true });
}

export function logout() {
  setAuth({ user: null, token: null, isAuthenticated: false });
}

export { auth, setAuth };
// Any component file
import { auth, logout } from "./authStore";

function Navbar() {
  return (
    <nav>
      {auth.isAuthenticated ? (
        <div>
          <span>Welcome, {auth.user?.name}</span>
          <button onClick={logout}>Log Out</button>
        </div>
      ) : (
        <button>Log In</button>
      )}
    </nav>
  );
}

Common State Management Patterns

The Factory Pattern for Reusable State

When you need to create multiple independent instances of the same state logic, the factory pattern is ideal. This is particularly useful for form management, feature-specific state, or any reusable widget.

import { createSignal, createStore } from "solid-js";

function createTodoList() {
  const [todos, setTodos] = createStore<{ id: number; text: string; done: boolean }[]>([]);
  const [filter, setFilter] = createSignal<"all" | "active" | "completed">("all");
  const [nextId, setNextId] = createSignal(1);

  const addTodo = (text: string) => {
    setTodos((prev) => [...prev, { id: nextId(), text, done: false }]);
    setNextId((id) => id + 1);
  };

  const toggleTodo = (id: number) => {
    setTodos(
      (todo) => todo.id === id,
      "done",
      (done) => !done
    );
  };

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

  const filteredTodos = () => {
    const f = filter();
    if (f === "all") return todos;
    if (f === "active") return todos.filter((t) => !t.done);
    return todos.filter((t) => t.done);
  };

  return {
    todos: filteredTodos,
    filter,
    setFilter,
    addTodo,
    toggleTodo,
    removeTodo
  };
}

// Usage
function TodoApp() {
  const todoList = createTodoList();

  return (
    <div>
      <input
        type="text"
        placeholder="New todo..."
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            todoList.addTodo(e.currentTarget.value);
            e.currentTarget.value = "";
          }
        }}
      />
      <ul>
        {todoList.todos().map((todo) => (
          <li>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => todoList.toggleTodo(todo.id)}
            />
            {todo.text}
            <button onClick={() => todoList.removeTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

The Reducer Pattern

For complex state transitions that benefit from explicit action handling, you can implement a reducer pattern in SolidJS. This brings predictability and makes state changes easier to trace and test.

import { createStore } from "solid-js/store";

type State = {
  count: number;
  history: number[];
};

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" }
  | { type: "set"; value: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1, history: [...state.history, state.count + 1] };
    case "decrement":
      return { count: state.count - 1, history: [...state.history, state.count - 1] };
    case "reset":
      return { count: 0, history: [] };
    case "set":
      return { count: action.value, history: [...state.history, action.value] };
    default:
      return state;
  }
}

function createReducer(initialState: State) {
  const [state, setState] = createStore(initialState);

  const dispatch = (action: Action) => {
    setState(reducer(state, action));
  };

  return [state, dispatch] as const;
}

function CounterWithHistory() {
  const [state, dispatch] = createReducer({ count: 0, history: [] });

  return (
    <div>
      <p>Count: {state.count}</p>
      <p>History: {state.history.join(", ")}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </div>
  );
}

Popular State Management Libraries for SolidJS

Solid Primitives

The @solid-primitives ecosystem offers a rich set of utilities that extend SolidJS's built-in capabilities. These are community-maintained packages that follow SolidJS's reactive philosophy.

import { createMutable } from "solid-js/store";
import { createLocalStorage } from "@solid-primitives/storage";

function SettingsPanel() {
  // Persists state to localStorage automatically
  const [settings, setSettings] = createLocalStorage("app-settings", {
    name: "Default User",
    notifications: true,
    volume: 50
  });

  return (
    <div>
      <label>
        Name:
        <input
          type="text"
          value={settings.name}
          onInput={(e) => setSettings("name", e.currentTarget.value)}
        />
      </label>
      <label>
        <input
          type="checkbox"
          checked={settings.notifications}
          onChange={(e) => setSettings("notifications", e.currentTarget.checked)}
        />
        Enable Notifications
      </label>
      <label>
        Volume: {settings.volume}
        <input
          type="range"
          min="0"
          max="100"
          value={settings.volume}
          onInput={(e) => setSettings("volume", Number(e.currentTarget.value))}
        />
      </label>
    </div>
  );
}

Solid-Query for Server State

For managing server state โ€” data fetched from APIs โ€” @tanstack/solid-query is the go-to solution. It handles caching, background refetching, optimistic updates, and synchronization.

import {
  QueryClient,
  QueryClientProvider,
  createQuery,
  createMutation
} from "@tanstack/solid-query";

const queryClient = new QueryClient();

type Post = { id: number; title: string; body: string };

function fetchPosts(): Promise<Post[]> {
  return fetch("https://jsonplaceholder.typicode.com/posts")
    .then((res) => res.json());
}

function PostsList() {
  const query = createQuery(() => ({
    queryKey: ["posts"],
    queryFn: fetchPosts
  }));

  return (
    <div>
      {query.isLoading && <p>Loading posts...</p>}
      {query.isError && <p>Error: {query.error?.message}</p>}
      {query.isSuccess && (
        <ul>
          {query.data?.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
      <button onClick={() => query.refetch()}>Refresh</button>
    </div>
  );
}

function CreatePost() {
  const mutation = createMutation(() => ({
    mutationFn: (newPost: Partial<Post>) =>
      fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newPost)
      }).then((res) => res.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    }
  }));

  return (
    <div>
      <button
        onClick={() =>
          mutation.mutate({ title: "New Post", body: "Hello World", userId: 1 })
        }
        disabled={mutation.isPending}
      >
        {mutation.isPending ? "Creating..." : "Create Post"}
      </button>
      {mutation.isError && <p>Error: {mutation.error?.message}</p>}
      {mutation.isSuccess && <p>Post created!</p>}
    </div>
  );
}

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <PostsList />
      <CreatePost />
    </QueryClientProvider>
  );
}

XState for Complex State Machines

When your application logic involves complex state transitions, guards, and side effects, @xstate/solid brings the power of finite state machines to SolidJS.

import { createMachine } from "xstate";
import { useMachine } from "@xstate/solid";

const toggleMachine = createMachine({
  id: "toggle",
  initial: "inactive",
  states: {
    inactive: {
      on: { TOGGLE: "active" }
    },
    active: {
      on: { TOGGLE: "inactive" }
    }
  }
});

function Toggle() {
  const [state, send] = useMachine(toggleMachine);

  return (
    <button onClick={() => send({ type: "TOGGLE" })}>
      {state.matches("inactive") ? "Turn On" : "Turn Off"}
      (State: {state.value})
    </button>
  );
}

Best Practices

Choose the Right Primitive

Use signals for simple, independent values like a toggle, a counter, or a text input. Use stores for structured, nested data like form state, user profiles, or collections. Using a signal for a deeply nested object forces you to replace the entire object on every update, defeating the purpose of fine-grained reactivity. Conversely, using a store for a single boolean adds unnecessary overhead.

Avoid Premature Abstraction

SolidJS's primitives are already powerful abstractions. Don't reach for a state management library until you feel the pain of managing state with the built-in tools. Many applications can be fully built with signals, stores, and context alone.

Keep Reactive Scopes Clean

Because SolidJS tracks dependencies automatically, be careful about where you call signal getters. Calling a getter outside a reactive scope (like in an event handler) simply returns the current value without subscribing. Calling it inside a createEffect or JSX expression establishes a subscription. Understanding this distinction prevents subtle bugs.

import { createSignal, createEffect } from "solid-js";

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

  // This effect subscribes to count
  createEffect(() => {
    console.log("Count changed:", count());
  });

  const handleClick = () => {
    // This just reads the value, no subscription
    console.log("Current count:", count());
    setCount(count() + 1);
  };

  return <button onClick={handleClick}>{count()}</button>;
}

Separate Server State from Client State

Server state (data fetched from APIs) has different requirements than client state (UI toggles, form inputs, local filters). Server state needs caching, invalidation, retries, and background synchronization. Use a dedicated tool like TanStack Query for server state and keep your stores and signals for client-side concerns. Mixing them leads to complex, hard-to-maintain code.

Use TypeScript for Type Safety

SolidJS has excellent TypeScript support. Always type your stores, signals, and context values. This catches errors at compile time and improves developer experience with autocompletion.

import { createStore } from "solid-js/store";

interface UserState {
  id: string | null;
  name: string;
  email: string;
  preferences: {
    theme: "light" | "dark";
    language: string;
  };
}

const [user, setUser] = createStore<UserState>({
  id: null,
  name: "",
  email: "",
  preferences: {
    theme: "light",
    language: "en"
  }
});

// TypeScript ensures type-safe updates
setUser("preferences", "theme", "dark"); // โœ… Valid
// setUser("preferences", "theme", "blue"); // โŒ Type error

Batch Updates When Possible

SolidJS automatically batches updates within event handlers and effects. However, if you're making multiple asynchronous updates, consider using batch to prevent intermediate re-renders.

import { createSignal, batch } from "solid-js";

function DataSync() {
  const [users, setUsers] = createSignal([]);
  const [posts, setPosts] = createSignal([]);
  const [loading, setLoading] = createSignal(false);

  const syncAll = async () => {
    setLoading(true);
    const [usersData, postsData] = await Promise.all([
      fetch("/api/users").then((r) => r.json()),
      fetch("/api/posts").then((r) => r.json())
    ]);

    // Batch updates to avoid multiple reactive triggers
    batch(() => {
      setUsers(usersData);
      setPosts(postsData);
      setLoading(false);
    });
  };

  return (
    <button onClick={syncAll}>
      {loading() ? "Syncing..." : "Sync Data"}
    </button>
  );
}

Conclusion

State management in SolidJS is both flexible and powerful, thanks to its fine-grained reactivity system. By understanding the core primitives โ€” signals, stores, and memos โ€” you can handle most state management needs directly. For more complex scenarios, patterns like factories, reducers, and context provide structure, while libraries like TanStack Query and XState address specific concerns around server state and state machines. The key is to start simple with the built-in tools, introduce abstractions only when needed, and always keep the distinction between server state and client state clear. With these patterns and best practices in your toolkit, you'll be well-equipped to build robust, performant SolidJS applications of any size.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles