# unicycle

> Model-view-intent framework built on top of Vite and Cycle.js.

Latest version **1.0.5** (published 2026-04-07) · MIT license · 0 weekly downloads

## Install

```sh
npm install unicycle
pnpm add unicycle
yarn add unicycle
bun add unicycle
```

## Health

**Score 50/100 (C)** — status: active.

Positive: esm support; no vulnerabilities.

Warnings: low downloads; no types.

## Facts

| | |
|---|---|
| Version | 1.0.5 |
| Published | 2026-04-07 |
| First published | 2026-03-21 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | none |
| Module format | ESM + CommonJS |
| Dependencies | 0 |
| Unpacked size | 434.2 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| GitHub stars | 0 |
| Author | Uplift Systems Inc. |
| Maintainers | michael-visnyai |

## Links

- npm: https://www.npmjs.com/package/unicycle
- Repository: https://github.com/Uplift-Systems/unicycle
- Homepage: https://github.com/Uplift-Systems/unicycle#readme
- Issues: https://github.com/Uplift-Systems/unicycle/issues
- npm.io page: https://npm.io/package/unicycle

## Recent versions

- 1.0.5 (latest) — 2026-04-07
- 1.0.4 — 2026-04-06
- 1.0.3 — 2026-04-06
- 1.0.2 — 2026-04-02
- 1.0.1 — 2026-03-21
- 1.0.0 — 2026-03-21

## README

# Unicycle

Unicycle is a lightweight, routed model-view-intent (MVI) plus sinks framework built on top of [Cycle.js](https://cycle.js.org), [Vite](https://vite.dev), and [xstream](https://github.com/staltz/xstream).

It assumes that you use the aforementioned tooling. This framework is very opinionated in that way.

## Table of Contents

1. [Getting Started](#getting-started)
    1. [Installation](#installation)
    2. [Project Structure](#project-structure)
    3. [Vite Configuration](#vite-configuration)
2. [Model-View-Intent Basics](#model-view-intent-basics)
    1. [App-Level MVI](#app-level-mvi)
    2. [Route-Level MVI](#route-level-mvi)
3. [Wrapping it Up](#wrapping-it-up)

## Getting Started

### Installation

First, install the Unicycle package.

```bash
npm install unicycle
```

Next, install core dependencies.

```bash
npm install @cycle/dom xstream
npm install -D vite
```

When 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:

```JSON
{
  "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.js
```

The `/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:

```JavaScript
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](https://vite.dev) 7 to 8, there have been changes in how CommonJS and `default` import is handled.

Read [this guide](https://vite.dev/guide/migration#consistent-commonjs-interop) 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:

```JavaScript
// 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

```JavaScript
// 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](https://cycle.js.org/api/http.html)).

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`.

```JavaScript
// 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`.

```JavaScript
// 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`.

```JavaScript
// 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:

```JavaScript
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:

```JavaScript
{
  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:

```JavaScript

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`.

```JavaScript
// 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:

```JavaScript
{
  ...
  edit: {
    first_name: on.input({
      field: "first_name",
    }).map((event) => event.target.value),
  },
  ...
}
```

We can represent the model as such:

```JavaScript
{
  ...
  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.

```JavaScript
// 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`.

```JavaScript
// 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:

```JavaScript
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.

---
_Source: https://npm.io/package/unicycle · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
