Skip to content

hyperpolymath/rescript-tea

rescript-tea

The Elm Architecture (TEA) for ReScript, providing a principled way to build web applications with guaranteed state consistency, exhaustive event handling, and time-travel debugging.

Overview

rescript-tea implements The Elm Architecture pattern for ReScript with React integration. It provides:

  • Single source of truth - One model, one update pathway

  • Pure updates - Easy to test, easy to reason about

  • Type-safe - Compiler catches missing message handlers

  • React integration - Uses React for rendering, compatible with existing components

  • Commands & Subscriptions - Declarative side effects

Installation

npm install rescript-tea

Add to your rescript.json:

{
  "bs-dependencies": ["rescript-tea", "@rescript/react"]
}

Quick Start

open Tea

// 1. Define your model
type model = {count: int}

// 2. Define your messages
type msg =
  | Increment
  | Decrement

// 3. Initialize your app
let init = () => ({count: 0}, Cmd.none)

// 4. Handle updates
let update = (msg, model) => {
  switch msg {
  | Increment => ({count: model.count + 1}, Cmd.none)
  | Decrement => ({count: model.count - 1}, Cmd.none)
  }
}

// 5. Render your view (with dispatch for event handling)
let view = (model, dispatch) => {
  <div>
    <button onClick={_ => dispatch(Decrement)}> {React.string("-")} </button>
    <span> {model.count->Belt.Int.toString->React.string} </span>
    <button onClick={_ => dispatch(Increment)}> {React.string("+")} </button>
  </div>
}

// 6. Declare subscriptions (none for this simple example)
let subscriptions = _model => Sub.none

// 7. Create the app component
module App = MakeWithDispatch({
  type model = model
  type msg = msg
  type flags = unit
  let init = _ => init()
  let update = update
  let view = view
  let subscriptions = subscriptions
})

// 8. Mount it
switch ReactDOM.querySelector("#root") {
| Some(root) => {
    let rootElement = ReactDOM.Client.createRoot(root)
    rootElement->ReactDOM.Client.Root.render(<App flags=() />)
  }
| None => ()
}

Core Concepts

Model

Your application state is a single value (typically a record):

type model = {
  user: option<user>,
  posts: array<post>,
  loading: bool,
}

Messages

All possible events are variants of a single type:

type msg =
  | FetchPosts
  | GotPosts(result<array<post>, error>)
  | SelectPost(int)
  | Logout

Update

A pure function that handles messages:

let update = (msg, model) => {
  switch msg {
  | FetchPosts => (model, fetchPostsCmd)
  | GotPosts(Ok(posts)) => ({...model, posts, loading: false}, Cmd.none)
  | GotPosts(Error(_)) => ({...model, loading: false}, Cmd.none)
  | SelectPost(id) => ({...model, selectedId: Some(id)}, Cmd.none)
  | Logout => ({...model, user: None}, Cmd.none)
  }
}

Commands

Descriptions of side effects to perform:

// Do nothing
Cmd.none

// Batch multiple commands
Cmd.batch([cmd1, cmd2, cmd3])

// Perform an async operation
Cmd.perform(() => fetchUser("alice"), user => GotUser(user))

// Handle potential failures
Cmd.attempt(() => fetchUser("alice"), result => GotUser(result))

Subscriptions

Declarations of external event sources:

let subscriptions = model => {
  if model.timerRunning {
    Sub.Time.every(1000, time => Tick(time))
  } else {
    Sub.none
  }
}

Built-in subscriptions:

  • Sub.Time.every(ms, toMsg) - Timer

  • Sub.Keyboard.downs(toMsg) - Key down events

  • Sub.Keyboard.ups(toMsg) - Key up events

  • Sub.Mouse.clicks(toMsg) - Mouse clicks

  • Sub.Mouse.moves(toMsg) - Mouse movement

  • Sub.Window.resizes(toMsg) - Window resize

Modules

Tea.Cmd

Commands for side effects.

Tea.Sub

Subscriptions for external events.

Tea.Json

Type-safe JSON decoding:

open Tea.Json

let userDecoder = map3(
  (id, name, email) => {id, name, email},
  field("id", int),
  field("name", string),
  field("email", string),
)

// Use it
switch decodeString(userDecoder, jsonString) {
| Ok(user) => // use user
| Error(err) => Console.log(errorToString(err))
}

Tea.Html

Optional HTML helpers (you can also use JSX directly):

open Tea.Html

let view = model => {
  div([className("container")], [
    h1([], [text("Hello")]),
    button([onClick(Increment)], [text("+")]),
  ])
}

Tea.Test

Testing utilities:

// Simulate a sequence of messages
let finalModel = Tea.Test.simulate(
  ~init,
  ~update,
  ~msgs=[Increment, Increment, Decrement],
)

// Collect commands for inspection
let cmds = Tea.Test.collectCmds(
  ~init,
  ~update,
  ~msgs=[FetchUser("alice")],
)

Examples

See the examples/ directory:

  • 01_counter/ - Basic counter

Architecture: React Hooks Integration

rescript-tea uses React hooks internally to implement the TEA runtime:

  • useState - Stores the model state

  • useRef - Maintains cleanup functions for subscriptions

  • useEffect - Executes commands and manages subscription lifecycle

  • useCallback - Memoizes the dispatch function

This provides a seamless integration with React while maintaining TEA’s guarantees.

Why TEA?

Bug Type How TEA Prevents It

Stale UI

View is pure function of Model

Forgotten state updates

View recomputes entirely

Unhandled events

Variant types = compiler warnings

Race conditions

Single update pathway

Untestable code

Pure functions = easy testing

License

This project is dual-licensed under:

See CONTRIBUTING for details on how to contribute.

RSR Compliance

This repository follows the Rhodium Standard Repositories specification.

About

ReScript implementation of The Elm Architecture

Topics

Resources

License

Unknown, Unknown licenses found

Licenses found

Unknown
LICENSE.txt
Unknown
LICENSE-PALIMPSEST.txt

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

Packages

No packages published

Contributors 3

  •  
  •  
  •