image:[Palimpsest-MPL-1.0,link="https://github.com/hyperpolymath/palimpsest-license"] :toc: macro :toc-title: Contents :toclevels: 3 :icons: font :source-highlighter: rouge :experimental:
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.
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
npm install rescript-teaAdd to your rescript.json:
{
"dependencies": ["rescript-tea", "@rescript/react"]
}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 => ()
}Your application state is a single value (typically a record):
type model = {
user: option<user>,
posts: array<post>,
loading: bool,
}All possible events are variants of a single type:
type msg =
| FetchPosts
| GotPosts(result<array<post>, error>)
| SelectPost(int)
| LogoutA 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)
}
}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))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
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))
}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("+")]),
])
}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.
| 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 |
No Hidden Runtime Dependencies
rescript-tea has zero hidden runtime dependencies. The only runtime dependencies are the explicitly declared peer dependencies:
-
react/react-dom- For rendering -
@rescript/react- ReScript React bindings -
rescript- ReScript compiler/runtime
All functionality is implemented in pure ReScript with no external JavaScript libraries bundled or required. What you see in peerDependencies is exactly what you get.
The public API is explicitly defined in .resi interface files:
-
Tea.resi- Main module re-exports -
Tea_Cmd.resi- Command types and constructors -
Tea_Sub.resi- Subscription types and constructors -
Tea_Json.resi- JSON decoding combinators -
Tea_Http.resi- HTTP request helpers -
Tea_Html.resi- HTML element helpers -
Tea_App.resi- Application functors -
Tea_Test.resi- Testing utilities
Only types and functions exposed in these interfaces are part of the public API. Internal implementation details may change between versions, but the public API follows semantic versioning:
-
Patch (0.0.x): Bug fixes, no API changes
-
Minor (0.x.0): Additions only, backwards compatible
-
Major (x.0.0): Breaking changes (rare, well-documented)
This project is dual-licensed under:
See CONTRIBUTING for details on how to contribute.
This repository follows the Rhodium Standard Repositories specification.