В этой статье познакомимся с SolidJS − JavaScript-библиотекой для создания пользовательских интерфейсов без виртуального DOM. Мы создадим легкий список задач с использованием TypeScript и разберем некоторые особенности библиотеки.

Что такое SolidJS?
SolidJS является JS библиотекой с открытым исходным кодом. Сами разработчики пишут на своем гитхабе: «Solid − это декларативная библиотека JavaScript для создания пользовательских интерфейсов. Вместо использования виртуального DOM он компилирует свои шаблоны в реальные узлы DOM и обновляет их с помощью детализированных реакций».
Уже в описании можно понять с какой популярной библиотекой идет сравнение про виртуальный DOM. SolidJS похож на React, предоставляя нам возможности компонентной архитектуры, хранение и обновление данных с помощью сигналов (реактовские хуки), а также пару интересных возможностей.
У SolidJS достаточно хорошая документация на русском языке и имеет ряд туториалов. Однако, в русскоязычном сегменте мало применения этой библиотеки на практике, поэтому эта статья посвящена именно этому вопросу.
Начало работы
Нам нужен пакетный менеджер npm и среда разработки VS Code. Также будем использовать сборщик Vite (можно использовать и webpack).
В терминале перейдем в директорию, где будет размещена папка с проектом, и запустим следующую команду: npm create vite@latest todo-list -- --template solid-ts
Эта команда создаст нам директорию todo‑list и некоторые начальные файлы.
Перейдем в папку проекта: cd todo-listи выполним стандартную команду установки пакетов: npm install
Запустим проект и проверим, что все работает: npm run dev
Если мы все сделали правильно, то на адресе http://localhost:5173/ увидим эту страницу:

Дополнительно
Для стилизации добавим свободный набор с готовыми стилями − Bootstrap. В файле index.html вставим следующие строчки:
Перед закрывающим тегом </head>:
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
Перед закрывающим тегом </body>:
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
Также удалим файл App.css. В index.css удалим содержимое и вставим свое:
:root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; width: 100%; height: 100%; } #root { max-width: 1280px; margin: 0 auto; padding: 2rem; display: flex; flex-flow: column nowrap; place-items: center; }
Структура
Наше будущее приложение будет состоять из трех компонентов:
Задача − флажок «выполнена/не выполнена» и название задачи.
Текстовое поле для ввода задачи.
Кнопка для добавления/удаления задачи.
В итоге у нас должно получиться вот такое готовое приложение.
Первые компоненты
Задача
В папке src создадим новый файл components/task/index.tsx и в нем создадим компонент для отображения одной задачи:
export default function Task() { return ( <div class="row form-check"> <input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" /> <label class="form-check-label" for="flexCheckDefault"> Задача №1 </label> </div> ); }
В input мы будем передавать значение выполнена или не выполнена задача и рисовать на основе этого флажок, а в тег label мы передадим название задачи. Эти значения будем получать из параметров (далее − пропсов) компонента, представлены в объекте props (код ниже).
Теперь добавим адаптивности. В пропсы будем передавать название и статус (выполнена/не выполнена). Здесь наступает первый интересный момент в SolidJS.
Все динамические изменения в SolidJS − это реактивность. Здесь она нам нужна,чтобы мы могли динамически менять статус задачи без перерисовки страницы. Чтобы реактивность работала правильно, нельзя деструктуризировать объект props, как в React. Пропсы в SolidJS доступны только для чтения и уже имеют реактивные свойства, при деструктуризации эти свойства теряются, поэтому очень важно сохранить объект props.
Но если мы хотим задать некоторые параметры по умолчанию, то как это сделать? В таком случае нам необходим метод − mergeProps. В mergeProps необходимо передать объект со значениями пропсов по умолчанию и объект входящих пропсов, в итоге у нас получится новая переменная:
import { mergeProps } from "solid-js"; type Props = { name?: string; isDone?: boolean; }; export default function Task(_props: Props) { const props = mergeProps( { name: "Задача №1", isDone: false, }, _props ); return ( <div class="form-check"> <input class="form-check-input" type="checkbox" value="" id="flexCheckDefault" checked={props.isDone} /> <label class="form-check-label" for="flexCheckDefault"> {props.name} </label> </div> ); }
Теперь перейдем в App.tsx, удалим весь код, что там есть, и вставим свой:
import Task from "./components/task"; function App() { return ( <div> <Task /> </div> ); } export default App;
Опять перейдем на http://localhost:5173/ и увидим наш результат (проект должен быть запущен):

Отлично! Полдела сделано, осталось только создать еще парочку компонентов.
Поле ввода задачи
Создадим файл components/input-field/index.tsx и запишем следующее:
type Props = { onChanged: () => void; }; export default function InputField(props: Props) { return ( <div class="mb-3"> <label for="exampleFormControlInput1" class="form-label"> Введите название задачи </label> <input type="email" class="form-control form-control" id="exampleFormControlInput1" placeholder="Прочитать статью..." onChange={props.onChanged} /> </div> ); }
В input будем записывать наши задачи, поэтому необходим метод для отслеживания изменений − onChanged. В этом компоненте уже не будем определять пропсы по умолчанию, сделаем функцию onChanged обязательной для вызова этого компонента.
Кнопка для удаления или добавления задачи
Создадим один компонент для двух действий и в пропсах будем передавать тип кнопки. В файле components/button/index.tsx запишем:
type Props = { name: string; type: "add" | "delete"; onClick: () => void; }; export default function Button(props: Props) { return ( <button type="button" class="btn btn-success" classList={{ "btn-succes": props.type === "add", "btn-danger": props.type === "delete", }} onClick={props.onClick} > {props.name} </button> ); }
Здесь также в просы прокидываем название для кнопки, обработчик события-клик и тип кнопки. У нас только два типа кнопок: удаление и добавление, поэтому можем записать их просто строчками ( на будущее лучше вынести в отдельную константу). Тип кнопки нужен для добавление стилей.
Как вы наверное уже заметили, SolidJS предоставляет нам целых два свойства для указания класса: class и classList. Первый принимает на вход строку с названием класса, последний − объект, у которого слева находится класс, а справа − значение типа boolean, которое в случае true добавит класс к элементу.
Теперь наша папка с проектом должна выглядеть примерно так:

Добавляем новые компоненты в App:
import Button from "./components/button"; import InputField from "./components/input-field"; import Task from "./components/task"; function App() { return ( <div> <InputField onChanged={() => console.log("input changed")} /> <Button name="Добавить" onClick={() => console.log("btn click")} type="add" /> <Task /> <Button name="Удалить" onClick={() => console.log("btn click")} type="delete" /> </div> ); } export default App;
Пока в компоненты передаем заглушки с выводом в консоль, позже это изменим.
Теперь наше приложение выглядит так:

Мда, пока не очень красиво, сейчас это исправим.
Добавим файл components/task-row/index.tsx с компонентом для отрисовки задачи и кнопки «Удалить» на одной строке:
import Button from "../button"; import Task from "../task"; export default function TaskRow() { return ( <div class="d-flex flex-row justify-content-between align-items-center gap-3"> <div class="flex-grow-1"> <Task /> </div> <Button name="Удалить" onClick={() => console.log("btn click")} type="delete" /> </div> ); }
И добавим в App несколько тегов:
import Button from "./components/button"; import InputField from "./components/input-field"; import TaskRow from "./components/task-row"; function App() { return ( <div class="container"> <div class="d-flex flex-row justify-content-between align-items-center gap-3"> <div class="flex-grow-1"> <InputField onChanged={() => console.log("input changed")} /> </div> <div class="mt-3"> <Button name="+ Добавить" onClick={() => console.log("btn click")} type="add" /> </div> </div> <div class="mb-3 d-flex flex-column gap-3"> <TaskRow /> </div> </div> ); } export default App;
Теперь наше приложение будет выглядеть более опрятно:

Первая реактивность
Теперь перейдем к самому интересному − логика нашего приложения. Добавим обработку добавления новой задачи.
В компоненте App создадим сигнал для хранения всех наших задач и определим тип ITask:
import { createSignal } from "solid-js"; ... export interface ITask { id: number; name: string; isDone: boolean; } function App() { const [tasks, setTasks] = createSignal<ITask[]>([]); const [taskId, setTaskId] = createSignal(0); const [newTask, setNewTask] = createSignal(""); ...
Вызов функции createSignal возвращает пару: значение + функция для изменения значения (тот же самый useState). Однако сигналы не привязаны к компонентам и эти строчки можно спокойно разместить перед компонентом App.
Мы создали несколько сигналов:
tasks − для хранения всех задач,
taskId − индекс для новой задачи (этот сигнал для учебных целей, можно обойтись и без него),
newTask − название для новой задачи.
Для создания новой задачи добавим в App.tsx следующую функциональность:
function App() { ... const handleAddClick = () => { if (newTask()) { setTasks([...tasks(), { id: taskId(), name: newTask(), isDone: false }]); setTaskId((prev) => prev + 1); setNewTask(""); } }; ... return ( <div class="container"> <div class="d-flex flex-row justify-content-between align-items-center gap-3"> <div class="flex-grow-1"> <InputField value={newTask()} onChanged={(value: string) => setNewTask(value)} /> </div> <div class="mt-3"> <Button name="+ Добавить" onClick={handleAddClick} type="add" /> </div> ... )}
В функции добавления новой задачи, проверяем значение newTask, вызванного с помощью геттер-метода (все значения сигналов в SolidJS получаются с помощью круглых скобок => newTask()). Если у нас непустая строка, то в setTasks добавляем новую задачу в конец списка tasks, увеличиваем индекс для следующей задачи и очищаем поле newTasks.
Для изменения значения сигнала используем сеттеры(методы установки), как setTaskId. Внутрь можно передать новое значение (как в setTasks) или преобразовать прошлое значение: setTaskId((prev) => prev + 1).
Обновим компонент InputField:
import type { JSX } from "solid-js"; type Props = { value: string; onChanged: (value: string) => void; }; export default function InputField(props: Props) { const handleOnChanged: JSX.EventHandler<HTMLInputElement, Event> = ( e: Event ) => { const target = e.target as HTMLInputElement; if (target) props.onChanged(target.value); }; return ( <div class="mb-3"> <label for="exampleFormControlInput1" class="form-label"> Введите название задачи </label> <input type="email" class="form-control form-control" id="exampleFormControlInput1" placeholder="Прочитать статью..." value={props.value} onChange={handleOnChanged} /> </div> ); }
Здесь обновили пропсы и добавили функцию-обработчик, которая из объекта события получает ссылку на DOM элемент input (e.target) и его значение передает в родительскую функцию, переданную через props.
Удаление. В компоненте App добавим функцию:
const handleDeleteClick = (id: number) => { setTasks(tasks().filter((item) => item.id !== id)); };
Здесь мы просто фильтруем значения по индексу задачи, сохраняем в итоговый сигнал те задачи, которые не равны переданному индексу.
А в компоненте TaskRow добавим обработчик нажатия на кнопку и передачу id задачи в функцию:
import { ITask } from "../../App"; import Button from "../button"; import Task from "../task"; type Props = { task: ITask; onDeleteClick: (id: number) => void; }; export default function TaskRow(props: Props) { const handleDeleteClick = () => { props.onDeleteClick(props.task.id); }; return ( <div class="d-flex flex-row justify-content-between align-items-center gap-3"> <div class="flex-grow-1"> <Task name={props.task.name} isDone={props.task.isDone} /> </div> <Button name="Удалить" onClick={handleDeleteClick} type="delete" /> </div> ); }
Ну и самое главное − вывести все наши задачи на экран. Для этого воспользуемся специальным тегом For и добавим его в App вместо строчки <TaskRow />:
<For each={tasks()}> {(item, _index) => <TaskRow task={item} onDeleteClick={handleDeleteClick} />} </For>
В атрибут each необходимо передать массив, потом, как в функции map, определить элемент и индекс. Только в нашем случае index будет являться сигналом для отслеживания перемещение строки независимо от изменений внутри элемента.
Почему рекомендуется использовать именно For для массивов?
В SolidJS рекомендуется также использовать именно встроенный инструмент For, нежели метод map, по причине реактивности. Готовый инструмент позволяет не перерисовывать несколько раз одни и те же данные и сохраняет реактивность.
Перейдем на наш сайт, теперь список задач пустой. Попробуем добавить новую задачу:


Вроде все работает!
Ну и конечно же, добавим переключение статуса задачи.
В компоненте Task обновим пропсы, добавив функцию обработчик:
type Props = { name: string; isDone: boolean; onIsDoneChanged: () => void; };
Добавим в input атрибут onChange и нашу функцию из пропсов:
<input ... onChange={props.onIsDoneChanged} />
А теперь выведем зачеркнутый текст, если задача выполнена:
<label class="form-check-label" for="flexCheckDefault"> <Show when={props.isDone} fallback={props.name}> <del>{props.name}</del> </Show> </label>
Продолжая об особенностях SolidJS − компонент Show. Этот к��мпонент оптимально обрабатывает условие в шаблонах (хотя SolidJS знает что такое &&, но в документации советуют использовать именно встроенный компонент).
Пропс fallback выполняет функцию else и будет показан в том случае если условие, которое мы передали в when вернет false значение.
В компоненте TaskRow также добавляем обработчик и обновляем пропсы:
type Props = { task: ITask; onDeleteClick: (id: number) => void; onIsDoneChanged: (id: number) => void; }; export default function TaskRow(props: Props) { ... const handleIsDoneChanged = () => { props.onIsDoneChanged(props.task.id); }; return ( ... <Task name={props.task.name} isDone={props.task.isDone} onIsDoneChanged={handleIsDoneChanged} /> ...
Ну и в компоненте App добавляем всю оставшуюся логику:
function App() { ... const handleIsDoneChanged = (id: number) => { setTasks( tasks().map((task) => task.id !== id ? task : { ...task, isDone: !task.isDone } ) ); }; return ( ... <TaskRow task={item} onDeleteClick={handleDeleteClick} onIsDoneChanged={handleIsDoneChanged} /> ...
Проверяем:

Отлично, все готово!
Еще больше реактивности
Для хранения всех задач мы используем сигнал с массивом, хотя каждая вложенная задача может менять значение. Вложенная реактивность реализуется в SolidJS с помощью специального метода createStore. Используем его для наших задач, вместо createSignal для tasks напишем следующее:
const [store, setStore] = createStore([] as ITask[]);
Обновим все функции в App:
const handleAddClick = () => { if (newTask()) { setStore((t) => [...t, { id: taskId(), name: newTask(), isDone: false }]); setTaskId((prev) => prev + 1); setNewTask(""); } }; const handleDeleteClick = (id: number) => { setStore((t) => t.filter((item) => item.id !== id)); }; const handleIsDoneChanged = (id: number) => { setStore( (todo) => todo.id === id, "isDone", (isDone) => !isDone ); };
Функции handleAddClick и handleDeleteClick оставим примерно как было, а в методе handleIsDoneChanged реализуем нашу вложенность. В первый параметр setStore передаем функцию для выбора нужного объекта, вторым аргументов − поле, которое необходимо изменить и последний аргумент − изменение самого поля. Насколько лаконично это выглядит!
И в тег For передадим наш стор:
<For each={store}> {(item, _index) => ( <TaskRow task={item} onDeleteClick={handleDeleteClick} onIsDoneChanged={handleIsDoneChanged} /> )} </For>
Теперь мы реализовали все согласно рекомендациям SolidJS и добились более простой реактивности в наших вложенных задачах.
Полный код проекта есть в git-репозитории, а итоговый проект доступен по ссылке.
В этой статье мы рассмотрели лишь часть возможностей библиотеки, но, надеюсь, после прочтения библиотека SolidJS стала для вас более понятной :) Пишите в комментариях, если у вас остались какие-либо вопросы.
