Skip to content

Quick Start

Sygnal is an intuitive framework for building fast, small, and composable web components and applications. It uses functional reactive programming under the hood but keeps the developer experience simple and familiar.

If you’ve used React, you’ll feel right at home. If you haven’t, don’t worry — Sygnal is easy to learn.

By the end of this guide, you’ll have a working Sygnal application with interactive components that respond to user input and manage state automatically.

  • Node.js (v12 or higher)
  • A package manager (npm, yarn, or pnpm)

The fastest way to get started is with create-sygnal-app:

Terminal window
npm create sygnal-app my-app

You’ll be prompted to choose a template and language:

  • Vite (SPA) — Single-page app with Vite + HMR
  • Vike (SSR) — File-based routing with SSR, layouts, and data fetching
  • Astro — Content-focused site with island hydration

Each template is available in JavaScript or TypeScript.

After scaffolding, start the dev server:

Terminal window
cd my-app
npm run dev

Open http://localhost:5173 in your browser (or the port shown in the terminal).

If you prefer to set things up yourself:

Terminal window
mkdir my-app && cd my-app
npm init -y
npm install sygnal
npm install -D vite

Create vite.config.js:

import { defineConfig } from 'vite'
import sygnal from 'sygnal/vite'
export default defineConfig({
plugins: [sygnal()],
})

The Sygnal Vite plugin configures JSX and HMR automatically. See Bundler Configuration for manual setup or other bundlers.

Create index.html:

<!DOCTYPE html>
<html>
<head>
<title>My Sygnal App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

Create src/RootComponent.jsx:

function RootComponent({ state }) {
return (
<div>
<h1>Hello {state.name}!</h1>
</div>
)
}
RootComponent.initialState = {
name: 'World'
}
export default RootComponent

Create src/main.js:

import { run } from 'sygnal'
import RootComponent from './RootComponent.jsx'
run(RootComponent)

Add a dev script to package.json:

{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}
Terminal window
npm run dev

Open your browser to http://localhost:5173 and you’ll see “Hello World!”.

Static content isn’t very exciting. Let’s make a counter that responds to clicks.

Replace src/RootComponent.jsx with:

function RootComponent({ state }) {
return (
<div>
<h1>Count: {state.count}</h1>
<button className="increment">+</button>
<button className="decrement">-</button>
</div>
)
}
// Set the starting state
RootComponent.initialState = {
count: 0
}
// Intent: WHEN should things happen?
RootComponent.intent = ({ DOM }) => ({
INCREMENT: DOM.select('.increment').events('click'),
DECREMENT: DOM.select('.decrement').events('click')
})
// Model: WHAT should happen?
RootComponent.model = {
INCREMENT: (state) => ({ count: state.count + 1 }),
DECREMENT: (state) => ({ count: state.count - 1 })
}
export default RootComponent

Save the file and watch the browser update. Click the buttons to see the count change.

Sygnal uses a pattern called Model-View-Intent (MVI):

  1. View (the function itself) — How things look. Renders the UI based on the current state.
  2. Intent (.intent) — When things happen. Listens for DOM events and maps them to named actions.
  3. Model (.model) — What happens. Defines how each action updates the state.

The data flows in one direction: DOM events → Intent → Model → State → View → DOM.

Let’s build a component with two-way data binding:

function Greeter({ state }) {
return (
<div>
<h1>Hello {state.name}!</h1>
<input className="name-input" value={state.name} />
</div>
)
}
Greeter.initialState = { name: 'World' }
Greeter.intent = ({ DOM }) => ({
CHANGE_NAME: DOM.input('.name-input').value()
})
Greeter.model = {
CHANGE_NAME: (state, name) => ({ name })
}
export default Greeter

Notice the .value() in the intent. This extracts e.target.value from the input event before passing it to the model. The name parameter in the model reducer receives the string directly. Other extraction helpers include .checked(), .data('name'), and .key() — see the Intent guide for details.

Components nest naturally, just like in React:

Header.jsx
function Header({ title }) {
return <h1>{title}</h1>
}
export default Header
RootComponent.jsx
import Header from './Header.jsx'
function RootComponent({ state }) {
return (
<div>
<Header title="My App" />
<p>Count: {state.count}</p>
<button className="increment">+</button>
</div>
)
}
RootComponent.initialState = { count: 0 }
RootComponent.intent = ({ DOM }) => ({
INCREMENT: DOM.select('.increment').events('click')
})
RootComponent.model = {
INCREMENT: (state) => ({ count: state.count + 1 })
}
export default RootComponent

By default, child components receive the entire application state. To give a child only a slice of state, use the state prop:

function RootComponent({ state }) {
return (
<div>
{/* UserCard only sees state.user as its root state */}
<UserCard state="user" />
</div>
)
}
RootComponent.initialState = {
user: { name: 'Alice', age: 30 },
settings: { theme: 'dark' }
}

Inside UserCard, state will be { name: 'Alice', age: 30 } — not the entire app state. If UserCard updates its state, those changes flow back up to state.user in the parent automatically.

Sygnal supports HMR out of the box, preserving your application state across code changes:

src/main.js
import { run } from 'sygnal'
import RootComponent from './RootComponent.jsx'
const { hmr, dispose } = run(RootComponent)
if (import.meta.hot) {
import.meta.hot.accept('./RootComponent.jsx', hmr)
import.meta.hot.dispose(dispose)
}

Now when you edit your component, the page updates without losing state.

If you prefer not to use JSX, import h from Sygnal and use hyperscript-style calls:

import { h, run } from 'sygnal'
function RootComponent({ state }) {
return h('div', [
h('h1', `Count: ${state.count}`),
h('button.increment', '+')
])
}
RootComponent.initialState = { count: 0 }
RootComponent.intent = ({ DOM }) => ({
INCREMENT: DOM.select('.increment').events('click')
})
RootComponent.model = {
INCREMENT: (state) => ({ count: state.count + 1 })
}
run(RootComponent)

Sygnal integrates natively with Astro:

astro.config.mjs
import { defineConfig } from 'astro/config'
import sygnal from 'sygnal/astro'
export default defineConfig({
integrations: [sygnal()]
})

Then use Sygnal components in .astro files with client directives:

---
import Counter from '../components/Counter.jsx'
---
<Counter client:load />

Sygnal integrates with Vike for file-based routing with SSR:

vite.config.js
import { defineConfig } from 'vite'
import sygnal from 'sygnal/vite'
import vike from 'vike/plugin'
export default defineConfig({
plugins: [sygnal({ disableHmr: true }), vike()],
})
pages/+config.js
import vikeSygnal from 'sygnal/config'
export default { extends: [vikeSygnal] }

Then create page components in pages/:

pages/index/+Page.jsx
function Page({ state }) {
return <h1>Count: {state.count}</h1>
}
Page.initialState = { count: 0 }
export default Page

See the Vike integration guide for layouts, data fetching, SPA mode, and more.

ConceptWhat It Does
View (function)Renders UI from state. Pure — no side effects.
Intent (.intent)Maps DOM events to named actions.
Model (.model)Defines state changes for each action.
State (.initialState)Sets the component’s starting state.
run()Bootstraps the application.
DriversHandle all side effects (DOM, state, logging, custom).
Error Boundaries (.onError)Catch rendering errors with fallback UI.
Refs (createRef())Access DOM elements declaratively.
Portal (<Portal>)Render children into a different DOM container.
Transition (<Transition>)CSS enter/leave animations.
Lazy (lazy())Code-split components with automatic loading placeholders.
Suspense (<Suspense>)Show fallback UI while children signal not-ready.
Disposal (dispose$)Run cleanup logic when components unmount.