Radish is a standards-first framework with a unified approach to building fullstack web apps.
- Unified Approach: A cohesive and simple mental model
- Standards-first: Embraces Web Components by focusing on the good parts
- Server-Side rendering: Supports declarative shadow root templates with SSR
- Declarative API: Declarative directives and signals for reactivity
- Readable code: Near-zero build step and no bundling, making code readable and debuggable
- Type Safety: Type-safe authoring
- Powerful Effect System
- Extensible Plugin API
- Secure by Default: Powered by Deno
The web platform is rapidly maturing, with features arriving at an unprecedented pace: HTML declarative shadow root, CSS functions, JS Signals, Navigation API, and more. Relying on the platform means less churn: web APIs evolve slowly, reducing migration overhead.
Today we can manage frontend dependencies with importmaps and create modular code with native ES modules. The future is moving beyond traditional bundlers, freeing us from JavaScript toolchain sprawl.
Radish is designed to offer top-tier features, DX, maintainability and future-proofing, while minimizing abstraction, bundling, and deviation from web standards.
Try it out, and you'll discover how refreshing it is to have readable and debuggable code in the browser at every stage (we just strip types). Radish deepens your understanding of platform technologies and helps you build more robust, future-proof applications. Its clear and coherent mental model helps everything click into place.
Create a new project:
deno run -A jsr:@radish/init@1.0.0-alpha.33 my-rad-project
Build your project:
deno task build
Start your project:
deno task start --dev
Have a look at the /app
folder of the repo for some syntax examples
Here's how simple it is to declaratively bind a checkbox to an element property:
import { HandlerRegistry, signal } from "radish";
// demo-bind.ts
class DemoBind extends HandlerRegistry {
isChecked = signal(true);
}
customElements.define("handle-input-demo", HandleInputDemo);
<demo-bind>
<input type="checkbox" bind:checked="isChecked" />
</demo-bind>
A Radish project looks like this:
my-rad-project/
├ elements/ <-- your custom elements, web components and unknown elements
├ lib/ <-- reusable ts modules
├ routes/ <-- routes and one-off colocated custom elements
├ static/ <-- static assets that should be served as-is
├ start.ts <-- start script and project config
└ deno.json
- Radish!
Radish simple mental model helps you make sense of all the moving parts in a fullstack app, binging them into a cohesive picture, and giving you a glimpse of the framework's modularity.
The effect-system is built around effects you perform, and handlers to interpret them, usually via plugins.
Note: awaiting effects
Effects are often sequenced in pipelines like read -> transform -> write, hinting at their monadic nature.
In Radish, handlers interpret the `Effect<T>` monad into the `Promise<T>` monad letting us await
them for clean, direct sequencing.
await
is just syntax sugar offered by the `PromiseLike` interface. It's the JS equivalent of Haskell's do-notation
Note: JS async marker and handler types
In JavaScript/TypeScript, asynchrony is the only effect we have markers for, with the `async` keyword and the `Promise` return type. Other effects (throwing, logging) have no markers.
One approach would be to encode all effects in types. This is the approach taken by the Effect framework.
Instead, Radish is a lightweight approach that embraces the JavaScript/TypeScript languages, with no need to wrap all your libraries and with no interop concerns: it's all standard JavaScript.
In operations signatures (see createEffect
), asynchrony is treated like any other JS effect: it's swallowed and we don't mark it in the operation signature. This provides a uniform treatment of effects in operation signatures as well as flexibility in how handlers are implemented: an operation signature corresponds to an effect-free signature, and being async becomes an implementation detail. This also lets handlers perform other effects (by awaiting them) and, by the current note, this is an implementation detail too.
The full documentation of the effect-system is available here
A plugin is just an object with a name and an array of handlers
import type { Plugin } from "radish/types";
export const myIOPlugin: Plugin = {
name: "my-io-plugin",
handlers: [
IOCountTXTReads,
IODecorateTXT,
IOHandleTXTOnly,
IOReadHandler,
IOWriteHandler,
],
};
When handlers rely on delegation (Handler.continue(...)
), the order
matters. Handlers are evaluated in sequence with the first handler of the list
being executed first.
All built-in plugin handlers in Radish are total, so you can safely build specialized handlers that delegate or decorate them.
Once your plugin is ready, extend Radish's behavior by prepending it to the
plugins
array of your config file.
All core framework features, like declarative shadow root inlining, server directives, type stripping etc., are implemented as built-in plugins. You can extend, override, or layer on top of them with the plugin API.
The provided plugins can be imported from radish/plugins
, see the
core/src/plugins
folder. Here's an overview
Radish uses a file-based router based on the
URLPattern
Web Standard. Routes correspond to subfolders of the routes
folder with an
index.html
file inside
Example: The folder structure
routes/
└ user/
└ profile/
└ index.html
corresponds to the route /user/profile
.
A dynamic segment can be provided with square brackets:
Example:
routes/
└ user/
└ [id]/
└ index.html
This folder structure corresponds to the named group /user/:id
and will match
against /user/123
for example
A non-captured group is delimited by curly braces {}
, and can be made optional
with the ?
group modifier.
Example: The pattern book{s}?
matches both /book
and /books
routes/
└ books{s}?/
└ index.html
To ensure a parameter is valid you can provide named Regex matchers to the router.
Example. To make sure a user id is a number, add the
router: { matchers: { number: /\d+/ } }
option to the config and update the
route:
routes/
└ user/
└ [id=number]/
└ index.html
Only non-empty numeric ids will match against this route, like /user/123
but
not /user/abc
.
The
Speculation Rules API
is supported with the generation of a speculationrules
script at build time
for instant page navigation. You can configure the ruleset in the generate.ts
script build options.
The elements
folder contains all three sorts of elements:
- custom elements, with no template and a only a class export
- unknown elements, with only an html template and no associated custom element
- web components, with both an html template and a custom element
The convention is that an element's folder and files are named after the element's tag name:
app/elements/my-element/my-element.html
contains the declarative shadow root template formy-element
.app/elements/my-element/my-element.ts
contains theMyElement
class defining the custom elementmy-element
.
Declarative shadowroot templates are inlined at build time
- Custom element templates inside
app/elements/
must have theshadowrootmode="open"
attribute to allow SSR.
You can write your modules in Typescript and type annotations will be removed with type-strip.
Only modern TypeScript is supported, equivalent to setting
--erasableSyntaxOnly
.
See the type-strip
unsupported features
for more.
This limitation is on purpose so that your code is not incompatible with the TC39 type annotations proposal.
Your custom elements are automatically imported by Radish where you use them.
For example if you have defined a my-button
web-component, then you can use it
directly in any page, and Radish will add the required import in the head of the
page:
<!-- This is automatically inserted in the head -->
<script type="module">
import "/elements/my-button/my-button.js";
</script>
Debugging your app is quite simple - and it's rather fun! - as Deno runs TypeScript source directly, so you can easily step through Radish very readable ts source code and not be confused by compiled/minified js.
A VS-Code launch.json
file is provided in the .vscode
folder of your app to
help in the process. Just pass it the args array of the script you want to debug
("--importmap"
, "--build"
etc) and launch the debug session!
In the browser debugging also works out of the box, and you can easily step through your code to understand what's going on, since the code running in the browser is just your TypeScript code with the types stripped out, which should be easy to read and a seamless experience.
A scoped handler registry is a custom element extending the HandlerRegistry
class. This is where you can define handlers for various directives listed
below.
Once in your markup, a handler registry handles all the interaction requests from its subtree of elements if it implements the requested handler. Handler registries are scoped: only the closest parent of a given element will handle its interactions if it can.
In this example, the handle-hover
custom element implements the showTooltip
event handler and the handle-click
implements handleClick
.
<handle-hover>
...
<handle-click>
...
<button on:click="handleClick" on:mouseover="showTooltip">click or hover me</button>
<handle-click>
<handle-hover>
This allows you to have a top-level registry implementing common handlers or hooks and removes the need for props drilling
The reactivity module is built around @preact/signals-core
and provides the
following helpers:
- the
signal<T>(value: T)
helper function creates a signal whose value can be accessed and modified in the code with the.value
property. Inside templates signals are coerced to their value and can be referenced directly without.value
Example: given the field name = signal("Radish")
in a parent handler, we can
reference it directly:
<parent-handler>{name}</parent-handler>
-
the
computed(computation: () => void)
helper creates a read-only computed signal based on the values of other signals and is used similarly to asignal
-
the
reactive<T>(value: T)
helper creates a deeply reactive object or array. A reactive object or array is proxied and its properties can be accessed directly without.value
const obj = reactive({ a: { b: 1 } }); // A reactive object
const a = computed(() => obj.a.b); // A reactive object is proxied and its properties can be accessed directly without `.value`
obj.a.b = 2; // Deep reactivity
console.log(a); // 2
- Handler Registries have a
this.effect(() => void)
method to create an effect which is automatically cleaned up when the element is disconnected. For advanced use cases an unowned effect can be created directly with theeffect
helper and accepts anAbortSignal
on
, prop
and use
only have client semantics while the other directives are
universal: they have both client and server semantics
The attr
directive sets an attribute on an element to the value referenced by
a given identifier. If the identifier is a signal, then the assignment is
reactive
<input type="checkbox" attr:disabled="isDisabled" />
If the attribute and the identifier have the same name we can use a shorthand notation:
<!-- these are equivalent -->
<input type="checkbox" attr:id />
<input type="checkbox" attr:id="id" />
In the previous example, the id
attribute of the input is bound to the id
property of its surrounding handler.
The bind
directive declares a two-way binding between an element stateful
property and a reactive signal.
For example to bind the checked
property of an input
to the isChecked
signal of a surrounding handler:
<demo-bind>
<input type="checkbox" bind:checked="isChecked" />
</demo-bind>
// demo-bind.ts
class DemoBind extends HandlerRegistry {
isChecked = signal(true);
}
If the property and the value have the same name you can use the following shorthand syntax:
<!-- these are equivalent -->
<input type="checkbox" bind:checked="checked" />
<input type="checkbox" bind:checked />
The bind
directive is a universal directive, with both client and server
semantics:
- On the server, it is equivalent to
attr
and sets the attribute to the given value. - On the client,
bind
is similar toprop
, with the signal value first resumed to the value of the HTML state, in case the user interacted before js was ready. Then the prop and state are kept in sync.
The resumability of the state on the client prevents janky hydration with slow networks. And focus is not lost in the process.
Also, the bind
directive allows cross-component bindings at any filiation
level: parents, grand-parents, grand-grand-parents etc.
You can use this directive on web components too. For example the following
my-rating
element and the input
are correlated via the value
signal of
their common handler:
<bind-custom-element>
<input type="number" bind:value>
<my-rating label="Rating" bind:value></my-rating>
<bind-custom-element>
class BindCustomElement extends HandlerRegistry {
value = signal(3);
}
The bool
directive handles custom boolean attribute bindings.
<demo-bool>
<label>
loading <input type="checkbox" name="circle" bind:checked="loading">
</label>
<sl-button size="medium" bool:loading>
<sl-icon name="gear" label="Settings"></sl-icon>
</sl-button>
</demo-bool>
class DemoBool extends HandlerRegistry {
loading = signal(true);
}
Toggling the checkbox will add or remove the loading
boolean
attribute on the sl-button
web component.
Global boolean attributes like disabled
, checked
etc.
can also be handled by the attr
and prop
directives.
The classList
directive accepts a reactive object where keys are strings of
space separated class names and values are boolean values or signals.
Example:
export class HandleClass extends HandlerRegistry {
outline = signal(false);
classes = reactive({
"red": false,
"outline": this.outline,
});
toggleColor() {
this.object.red = !this.object.red;
}
toggleOutline() {
this.outline.value = !this.outline.value;
}
}
<handle-class>
<p classList="classes">I have reactive classes</p>
<button on:click="toggleColor">toggle color</button>
<button on:click="toggleOutline">toggle outline</button>
</handle-class>
In this example clicking the buttons toggles the .red
and .outline
classes
on the paragraph element
The html
directive sets the innerHTML
property of an element. On the server
it parses the provided html string and inserts the resulting nodes as children
of the element.
The text
directive sets the textContent
property of an element. On the
server it creates a child text node inside on the element.
The on
directive allows to declaratively add event-handlers to any element:
<button on:click="handleClick" on:mouseover="handleMouseOver">
click or hover me
</button>
You can add multiple event handlers, even with the same event type, as on
is a
declarative way to addEventListener
. For example, this button has two click
event handlers:
<button on:click="handleClick" on:click="log">click me</button>
The prop
directive sets an element properties on the client.
It also gives fine grained control when you want to make sure js is available
like when toggling an aria property. In case js is not available the prop
effect doesn't run, so the property is not set and the element doesn't end-up
stuck in the wrong accessibility state.
The use
directive runs a lifecycle hook on an element.
<handle-hook>
<span use:hook>I'm hooked</span>
</handle-hook>
The closest handlers registry implementing the hook
method will handle it
export class HandleHook extends HandlerRegistry {
hook(element: Element) {
element.style.color = "red";
element.addEventListener("pointerover", () => {
element.style.color = "green";
});
}
}
You can use a hook defined in a parent handler registry, similar to if it were automatically passed via a context API
Use the head
element at the top level of pages to declaratively add content to
the document's head, like providing a title, description etc.
<head>
<title>The page title</title>
</head>
Building your projects mainly consists of stripping types, generating an importmap and applying server effects like declarative shadow root inlining.
When building your project, an
importmap
of your runtime dependencies is automatically generated and inserted in the
<head>
.
The importmap resolves modules from the esm.sh CDN:
- both npm and jsr modules are handled,
- the build target is automatically determined by checking the
User-Agent
header. So users of your site get precisely what they need
The importmap can be generated with the following command:
deno task generate --importmap
You have full control over the importmap in your config file, with options for manually including packages.
The importmap lets the browser resolve dependencies (and higher-order dependencies) from the esm.sh CDN. This means your code and its dependencies are not bundled together, and instead there is a clean separation between the code that you author and everything else. This allows them to move on asynchronously at their own pace and has several benefits:
- Better caching. Dependencies can be cached by the browser separately from your modules, e.g. updating a typo in your code only invalidates that file.
- Smaller downloads. Since dependencies are not inlined with your code, they're only downloaded on first load or whenever you update their version — not with every bundle.
- Less bandwidth usage. Resolving dependencies client-side and downloading them from CDNs means that much less traffic on your infrastructure. This can make a difference in terms of cost and usage.