unicycle
v1.0.5
Published
Model-view-intent framework built on top of Vite and Cycle.js.
Readme
Unicycle
Unicycle is a lightweight, routed model-view-intent (MVI) plus sinks framework built on top of Cycle.js, Vite, and xstream.
It assumes that you use the aforementioned tooling. This framework is very opinionated in that way.
Table of Contents
Getting Started
Installation
First, install the Unicycle package.
npm install unicycleNext, install core dependencies.
npm install @cycle/dom xstream
npm install -D viteWhen you set up your package, it assumes it is of type module.
This is the bare minimum of what your package.json should look like:
{
"name": "your-package",
"version": "x.x.x",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"type": "module",
"dependencies": {
"@cycle/dom": "x.x.x",
"unicycle": "x.x.x",
"xstream": "x.x.x"
},
"devDependencies": {
"vite": "x.x.x"
}
}Project Structure
A Unicycle project takes the following structure:
project-root/
├─src/
| ├─app/
| | ├─app.client.js
| | ├─app.intent.js
| | ├─app.model.js
| | └─app.view.js
| ├─components/
| ├─routes/
| | └─route_name
| | | ├─route_name.intent.js
| | | ├─route_name.model.js
| | | ├─route_name.route.js
| | | └─route_name.view.js
| ├─static/
| └─utils/
├─index.html
├─package.json
└─vite.config.jsThe /src/app folder contains your application-level code, which includes model, view, intent, and other sinks, which runs for every route.
The /src/routes folder contains each route as a sub-folder. Those contain your route-specific models, views, intents, and sinks.
Other optional folders for organization include a /src/components folder to store your view components. The /src/static folder can contain your static assets. The /src/utils can organize other utilities and helpers.
Vite Configuration
To run the Unicycle framework, use it as a plugin in your vite.config.js file as follows:
import UnicyclePlugin from "unicycle/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
UnicyclePlugin(),
],
legacy: {
inconsistentCjsInterop: true, // Workaround for import behavior, see below...
},
});If you're migrating from Vite 7 to 8, there have been changes in how CommonJS and default import is handled.
Read this guide for more information.
What this means in the context of Unicycle is that xstream may present issues with properly importing it. There are two workarounds.
First, either you can stick with Vite 7, or if you've upgraded to 8, use the inconsistentCjsInterop option as previewed in the configuration above.
This is not recommended by Vite as a long-term fix. So another way is to shim xstream by trying to target the correct export/import:
// Under /src/utils/xs.js
import * as xstream from "xstream";
const xs = xs?.default?.default ?? xs?.default ?? xs;
export default xs;This allows you to import xs from this file and get the proper export.
Usually, this error surfaces if you see something like TypeError: xs.of is not a function.
Model-View-Intent Basics
For example, one can create a simple "cookie clicker" application where clicking a button increments a counter and tells how many times the button has been clicked.
Although one can design this app non-routed, this example will show it routed to cover the entire Unicycle framework.
App-Level MVI
Starting at the app level, create /src/app/app.client.js. This provides application-level configuration.
App Configuration
// app.client.js
export default () => ({
drivers: {
// ... Additional drivers
},
defaults: {
// ... Default streams for each driver
},
state: {
// ... Initial app state
},
});The app-level configuration should export a function that returns an object which can contain drivers, defaults, and state.
Drivers are key-value pairs of the sink name to the makeDriver functions provided by any driver packages you install (e.g. @cycle/http).
Defaults is an object of key-value pairs of the sink name and the default stream you wish to be used if the route does not provide a sink.
For instance, if every route does not provide an http sink, you can define the default as http: xs.empty().
This is necessary for every driver you use.
Finally, state defines the initial app-level (or global) state.
Since this application is simple, we can even omit creating this file entirely.
App View
The next file is the view. This file is named /src/app/app.view.js.
// app.view.js
import { h1, main, p } from "@cycle/dom";
export default (dom, global) => main([
h1(["Cookie Clicker"]),
dom ?? p(["Page not found"]),
]);This view is rather simplistic. The app-level view should export a function that returns virtual DOM.
It takes in two parameters, dom and global.
The dom parameter is the route-level virtual DOM which can be "wrapped" by your app skeleton.
The global parameter is the app-level state.
Notice how we conditionally check if the dom is undefined, we supply a default which could be a page-not-found message.
This pretty much sums it up for the app-level files to create. But you can create other files like intent, model, and sinks for any of the drivers you've installed.
Route-Level MVI
Next, routes can be created, which are specific portions of your app that are accessed via its unique URL. Hence, why it's routed.
For our example cookie clicker project, we just need one route.
Example Route Definition
Create a folder under /src/routes to make a route. By nature, each route name (or id) must be unique.
For example, one can name this route index. So create a folder called /src/routes/index.
The required file for a route is the route definition. This file will be called index.route.js.
By convention, file names follow this pattern: [route_name].[file_type].js.
// index.route.js
export default () => ({
path: "/",
state: {
count: 0,
},
});This configuration allows you to specify two things. First, the path for the route can be specified either as a string, or an array of strings if the route has multiple paths.
You can also specify route parameters by prefixing any part of the slug with :parameter_name. So, parameter_name will be automatically made available under global.parameters in the model, for instance.
Second, the initial state can be defined for the route.
For our cookie clicker, we're interested in how many times the button is clicked, so we'll store a count value.
Intent Semantics
Next, one can write the intent. We'll name this file /src/routes/index/index.intent.js.
// index.intent.js
export default (on, global$) => ({
increment: on.click({
intent: "increment-count",
}),
});An intent file should export a function that returns an object representing an intent definition.
Intents can be described as a key-value pair of intent names to the intent listeners.
We provide two parameters to this function, on and global$.
The on parameter is an object already instantiated with the sources that come in from the drivers. This is a helper with easy, short-hand ways of writing intents.
For our application, we're interested to listen to when the button is clicked. So we simply define it as on.click({}).
We want to target a button with a data attribute of data-intent="increment-count".
Other event handlers are available as follows:
on.click() // Targets click events.
on.change() // Targets change events.
on.input() // Targets input events.
on.submit() // Targets submit events.
on.focusin() // Targets focusin events.
on.focusout() // Targets focusout events.
on.scroll() // Targets scroll events.
on.dblclick() // Targets dblclick events.
on.pointerdown() // Targets pointerdown events.
on.pointerup() // Targets pointerup events.
on.pointermove() // Targets pointermove events.
on.dragstart() // Targets dragstart events.
on.drag() // Targets drag events.
on.dragend() // Targets dragend events.
on.drop() // Targets drop events.
on.dragover() // Targets dragover events.
on.keydown().key("Enter") // Targets keydown events. Under the key, we can either provide a string, or an array of strings to match keys.
on.select() // Allows you to specify a target definition and returns the selection from the DOM driver.
on.sources // Returns the sources object passed in from the drivers.In terms of target definitions, there are standard semantics:
{
intent: "some-intent" // Targets an element with data-intent="some-intent"
}
{
field: "some-field" // Targets an element with data-field="some-field"
}
{
id: "some-id" // Targets an element with data-id="some-id"
}
{
index: "0" // Targets an element with data-index="0"
}
{
object: "some-object" // Targets an element with data-object="some-object"
}
{
accept: "some-accept" // Targets an element with data-accept="some-accept"
}You can even provide strings as literal query strings.
Targeting parents and children is possible too, by defining/nesting parent or child to other target definitions.
In summary, for a complex example, this is what a target definition/intent helper translates to:
on.click({
intent: "increment-count",
parent: "main",
})
// Is equivalent to...
sources.DOM.select("main [data-intent='increment-count']").events("click").filter((event) => event.currentTarget.ariaDisabled !== "true")Furthermore, when writing intents, either they can terminate in streams, or nested objects, or arrays of streams.
For our application, defining the one intent is sufficient.
Model Semantics
Next, a model can be created to translate intents into state changes.
We can create our model at /src/routes/index/index.model.js.
// index.model.js
export default (change) => ({
increment: ({state, action, global}) => {
const c = change();
const current_count = c.at("count").get(state);
c.at("count").set(current_count + 1);
return c;
},
});As you can see, our model for this application is trivial.
The model structure mirrors the intent. So whenever any intent fires, the corresponding model reducer definition is executed.
Models should export a function that returns an object containing its model definition. It accepts a parameter of change, which is a factory function for instantiating a change description to immutably modify state.
For our corresponding model to intent, increment, we supply a function that takes in a destructured object of state, action, and global.
The state parameter is the route's current state.
The action parameter is the value returned by the intent stream.
The global parameter is the app-level current state.
We initialize our change description by setting a variable to change(). Our model reducer should return that value. Conventionally, we can call this c.
And then we can apply any business logic and make changes to our state. We wish to do so immutably, so something like state.count++ would go against the grain of our pattern and potentially introduce hard-to-debug bugs.
So we immutably describe our changes and Unicycle takes care of applying them.
Our change operator has three main functions: get, set, and delete (as well as at).
First, we call the at function to specify a path to the state we wish to modify.
It can either be a string that can be dot-separated to target nested values. Or it can be an array of strings. For instance, c.at("nested.field") is equal to c.at(["nested", "field"]), which targets {nested: {field: "This Value"}}.
Then, we can use an operator to describe our change. The get operator takes in the object we wish to traverse (which is state in this case) for the value we want to know. This is just an accessor function, not a modification function.
The set operator overwrites the value of the path to the one we supply. In this case, we're changing count to the new value.
Finally, the delete operator removes the field from the state. It takes no parameters.
A note on nested intents, if we have an intent such as:
{
...
edit: {
first_name: on.input({
field: "first_name",
}).map((event) => event.target.value),
},
...
}We can represent the model as such:
{
...
edit: {
_: ({state, action, global}) => {...},
first_name: ({state, action, global}) => {...},
},
...
}The _ function is a special function that allows in nested intents, for the parent intent to fire any time a child intent fires. In this example, if the edit.first_name intent fires, the _ fires for any nested edit intent.
In summary, model definitions allow us to define changes to state in a structured way that mirrors the intent definition, and terminates in functions that return change descriptions.
Route Features
As an aside, there may be cases in real applications where the intent and model files grow where it makes sense to split them apart. This is what route features accomplish.
So we could have expressed our cookie clicker as a feature instead.
Features actually co-locate related intents and models. Features are placed under the route's folder.
So we can make a /src/routes/index/features folder and put an increment.js file as our increment feature.
// increment.js
export default () => ({
intent: (on, global$) => ({
increment: on.click({
intent: "increment-count",
}),
}),
model: (change) => ({
increment: ({state, action, global}) => {
const c = change();
const current_count = c.at("count").get(state);
c.at("count").set(current_count + 1);
return c;
},
}),
});It exports a function that returns an object which contains the intent and model definitions. The semantics for intents and models are the same here, and it combines it with the route's intent and model structure.
Route View
Finally, we can render our view. The view translates state into the visual interface.
We can create it at /src/routes/index/index.view.js.
// index.view.js
import { article, button } from "@cycle/dom";
export default (state, global) => article([
button({
dataset: {
intent: "increment-count",
},
}, `This button was clicked ${state.count} time${state.count !== 1 ? "s" : ""}`),
]);As you can see, the view should export a function that takes in state and global, which are the route's state and app's state respectively, and returns virtual DOM.
Our example is simple just returning a button showing how many times it was clicked.
And remember, this is combined with our app-level view we created earlier.
Route Sinks
If we had other drivers, we can also create other sink files. These should export functions that return streams.
So typically, it could take this structure:
import xs from "xstream";
export default ({on, action$, state$, global$} = {}) => xs.merge(
// ... Your streams
);The on function is the same from the intent.
The action$ stream is the stream of actions coming from your intents.
The state$ stream is the stream of your route's state.
The global$ stream is the stream of your app's state.
Wrapping it Up
This is a lightweight model-view-intent framework that brings structure to Cycle.js applications and allows them to be written with consistent semantics.
We hope this simple framework can serve you and your projects well by providing conventions and structure on top of what Vite and Cycle.js already offer.
Made with ❤️ by Uplift Systems Inc.
Copyright (c) 2025-2026 Uplift Systems Inc. All rights reserved.
