Introduction
SolidJS is revolutionizing frontend development in 2026 with its fine-grained reactivity, no virtual DOM, and no complex hooks like React. Unlike React, which re-renders entire trees, SolidJS uses signals—primitive reactive variables—to update only the affected DOM parts. This delivers native performance, smaller bundle sizes, and an intuitive developer experience.
This intermediate tutorial guides you through building a complete Todo App: list management, filtering, local persistence, routing, and a global store. Why SolidJS? Perfect for scalable apps where performance counts (dashboards, real-time editors). You'll start with a Vite setup, implement signals/effects/stores, and optimize for production. By the end, you'll have a bookmarkable, deployable project on Vercel/Netlify. Ready to level up your apps? (128 words)
Prerequisites
- Node.js 20+ installed
- Knowledge of JavaScript/TypeScript and JSX
- Familiarity with Vite or frontend bundlers
- Editor like VS Code with the SolidJS extension
Initialize the Vite + SolidJS Project
npm create vite@latest todo-solidjs -- --template solid-ts
cd todo-solidjs
npm install
npm install solid-router @solid-primitives/storage
npm run devThis command creates a Vite project with the SolidJS TypeScript template, installs base dependencies, adds solid-router for routing + @solid-primitives/storage for local persistence. Run npm run dev to start the server at http://localhost:5173. Avoid non-TS templates for type safety.
Project Structure
Your folder looks like this:
src/: Components, routes, styles.App.tsx: Entry point.index.html: Injects the script.
We'll replace
App.tsx with a multi-component Todo App. Signals handle local state (like useState but as proxies), stores manage global state (reactive objects/arrays). Next: the root component with routing.Set Up Routing and Root App
import { Router } from 'solid-router';
import { lazy } from 'solid-js';
import { routes } from './routes';
const TodoList = lazy(() => import('./components/TodoList'));
const App = () => {
return (
<Router source={window.location.pathname}>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8">
<header class="text-center mb-12">
<h1 class="text-5xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-4">Todo SolidJS</h1>
<nav class="flex justify-center space-x-4 text-lg">
<a href="/" class="text-blue-600 hover:underline">Accueil</a>
<a href="/about" class="text-blue-600 hover:underline">À propos</a>
</nav>
</header>
<main class="max-w-2xl mx-auto">
<TodoList />
</main>
</div>
</Router>
);
};
export default App;
export { routes };This code sets up Solid Router for a SPA with lazy-loading. Router handles routes, is the main component. Tailwind styles (via CDN in index.html) give it a pro look. Pitfall: Forgetting lazy bloats the initial bundle; use it for heavy components.
Implement the Global Todo Store
import { createStore } from 'solid-js/store';
import { createStorage } from '@solid-primitives/storage';
export type Todo = {
id: number;
text: string;
completed: boolean;
};
type TodoStore = {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
deleteTodo: (id: number) => void;
setFilter: (filter: TodoStore['filter']) => void;
clearCompleted: () => void;
};
const [state, setState] = createStore({
todos: [] as Todo[],
filter: 'all' as TodoStore['filter'],
});
const persistentState = createStorage({
storage: sessionStorage,
serializer: JSON,
})(state);
export const useTodoStore = (): TodoStore => {
const addTodo = (text: string) => {
const id = Date.now();
setState('todos', (todos) => [...todos, { id, text, completed: false }]);
};
const toggleTodo = (id: number) => {
setState('todos', (todos) =>
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id: number) => {
setState('todos', (todos) => todos.filter((t) => t.id !== id));
};
const setFilter = (filter: TodoStore['filter']) => setState('filter', filter);
const clearCompleted = () => {
setState('todos', (todos) => todos.filter((t) => !t.completed));
};
const filteredTodos = () => {
const { todos, filter } = persistentState;
switch (filter()) {
case 'active': return todos.filter((t) => !t.completed);
case 'completed': return todos.filter((t) => t.completed);
default: return todos;
}
};
return {
...persistentState,
filteredTodos,
addTodo,
toggleTodo,
deleteTodo,
setFilter,
clearCompleted,
};
};The store uses createStore for mutable global reactive state. @solid-primitives/storage persists to sessionStorage. Actions like addTodo update via setState with partial immutability. Pitfall: Always use store functions for reactivity; direct mutations break updates.
Using Signals and the Store
Analogy: A signal is like a watched counter; changing its value triggers only dependents. The store extends this to objects.
In TodoList, access the store via useTodoStore(). filteredTodos() is a computed proxy that re-runs on dep changes. Next: the TodoList component with form and list.
Create the Main TodoList Component
import { createSignal } from 'solid-js';
import { useTodoStore } from '../stores/todoStore';
export const TodoList = () => {
const [newTodo, setNewTodo] = createSignal('');
const store = useTodoStore();
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault();
if (newTodo().trim()) {
store.addTodo(newTodo().trim());
setNewTodo('');
}
};
return (
<div class="bg-white shadow-2xl rounded-2xl p-8 ring-1 ring-gray-200">
<form onSubmit={handleSubmit} class="mb-8 flex gap-4">
<input
type="text"
value={newTodo()}
onInput={(e) => setNewTodo(e.currentTarget.value)}
placeholder="Ajouter une nouvelle todo..."
class="flex-1 px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:border-transparent transition-all"
/>
<button
type="submit"
class="px-8 py-3 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-500 transition-all shadow-lg"
>
Ajouter
</button>
</form>
<div class="space-y-3">
{store.filteredTodos().map((todo) => (
<div
key={todo.id}
class="flex items-center p-4 bg-gray-50 rounded-xl hover:bg-gray-100 transition-all border-l-4 border-blue-500"
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => store.toggleTodo(todo.id)}
class="w-5 h-5 text-blue-600 rounded focus:ring-blue-500 mr-4"
/>
<span class={todo.completed ? 'line-through text-gray-500' : 'font-medium'}>
{todo.text}
</span>
<button
onClick={() => store.deleteTodo(todo.id)}
class="ml-auto px-4 py-2 text-red-500 hover:text-red-700 font-semibold transition-colors"
>
Supprimer
</button>
</div>
))}
</div>
{store.todos.length > 0 && (
<div class="mt-8 pt-8 border-t border-gray-200 flex justify-between items-center text-sm text-gray-600">
<span>{store.todos.length} todo(s)</span>
<div class="flex gap-2">
<button onClick={() => store.setFilter('all')} class={store.filter() === 'all' ? 'text-blue-600 font-semibold' : ''}>
Toutes
</button>
<button onClick={() => store.setFilter('active')} class={store.filter() === 'active' ? 'text-blue-600 font-semibold' : ''}>
Actives
</button>
<button onClick={() => store.setFilter('completed')} class={store.filter() === 'completed' ? 'text-blue-600 font-semibold' : ''}>
Terminées
</button>
{store.todos.some((t) => t.completed) && (
<button onClick={store.clearCompleted} class="text-red-500 hover:text-red-700">
Vider terminées
</button>
)}
</div>
</div>
)}
</div>
);
};This component uses a local newTodo signal for the input and the store for todos. onInput updates the signal, triggering targeted re-renders. Maps/lists are reactive via proxies. Pitfall: Use onInput not onChange for performance; unique key avoids unnecessary re-renders.
Add a Real-Time Stats Effect
import { createEffect, Show } from 'solid-js';
import { useTodoStore } from '../stores/todoStore';
export const Stats = () => {
const store = useTodoStore();
createEffect(() => {
console.log(`Todos filtrées: ${store.filteredTodos().length}`);
});
return (
<Show when={store.todos.length > 0}>
<div class="mt-4 p-4 bg-green-50 rounded-xl border border-green-200">
<p class="text-green-800 font-semibold">
{store.filteredTodos().length} / {store.todos.length} tâches {store.filter() === 'completed' ? 'terminées' : 'visibles'}
</p>
</div>
</Show>
);
};createEffect runs post-render when its deps change (here, the store). conditionally renders. Add in TodoList to see it. Pitfall: Avoid heavy side-effects in effects; they auto-track deps.
Update index.html for Tailwind
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<title>Todo SolidJS 2026</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>Adds Tailwind CDN for instant styles (prod: use PostCSS). entry-client.tsx is Vite's SolidJS bootstrap. Copy-paste for an immediate pro look.
Best Practices
- Always use unique keys in lists to optimize reconciliations.
- Prefer signals/stores over props drilling: They scale better.
- Lazy-load routes/components for bundles <100kb.
- Strict TypeScript: Enable
strict: truein tsconfig.json. - Tests with Vitest + Testing Library: Cover 80% of store actions.
Common Errors to Avoid
- Direct store mutation: Use
setStateimmer-style, or no reactivity. - Forgetting deps in createEffect: Add manually with
[dep1, dep2]if auto-tracking fails. - Signals in loops: Create them outside to avoid memory leaks.
- No storage cleanup: Use localStorage for forever persistence, sessionStorage for sessions.
Next Steps
- Official docs: SolidJS
- Solid Primitives: GitHub for reactive utils.
- Deploy to Vercel:
npm i -g vercel ; vercel.