Fast, Light weight (~5kb gzip), Component-based, JSX UI library.
Other libraries provide their own built-in “primitives” that you have to stick to. They restrict extensibility in a favor of concealing implementation details and don't give any explicit controls.
- No Framework
- Open Internals
- Fault Tolerance
- Customization
If you want manage your components in a somewhat complex way (like in React), you can continue reading this, but otherwise you may want to consider these alternatives:
bun i @denshya/proton
For the JSX and types to work properly, you should add this to your tsconfig.json
/jsconfig.json
vite
If you're using vite
, that's all configuration you need.
Other bundlers
For other bundlers you will need to tell them that you want to use @denshya/proton
as your JSX provider.
function App() {
return <div>Hello World!</div>
}
const inflator = new WebInflator
const AppView = inflator.inflate(<App />)
document.getElementById("root").replaceChildren(AppView)
Proton supports React-like JSX, except that it maps directly to Document elements and allows any value to be put into attributes or as children of any elements.
<div className="product-card">
<h2>Title</h2>
<p>Description</p>
<img src="/static/product-card.jpg" />
// You can put your weird staff.
<aside id={new MyIdObject} />
</div>
Learn more about Inflator to provide custom handlers.
To create a component, (currently) you need to create a function. It doesn't have any constraints on what can be returned.
const EmptyComponent = () => {} // Works.
function MyApp() { return <div></div> } // Works.
Props are passed the familiar way.
const EmptyComponent = (props) => {} // Works.
function MyApp(props) { return <div></div> } // Works.
Though there is a little difference: you can define initial props value. It will work in runtime, but (currently) you will have an error in TypeScript.
class MyAppProps {}
function MyApp(props = new MyAppProps) {
return <div></div>
}
Proton defines several "special" attributes for Intrinsic Elements for convenience's sake.
function MyApp() {
return (
<div
style={{ display: "absolute" }}
on={{ click: event => event.pointerId }}
>
// This element will be `SVGAElement` instead of `HTMLAnchorElement`
<a ns="http://www.w3.org/2000/svg" />
</div>
)
}
style
is special because it's extended to consume an Record object with various property types, including Observables.
Proton adopts observable as another primitive. Any object that has Symbol.subscribe
considered to be an observable.
Beyond that, it treats any object that has either get
or set
to be an accessor. Which can be combined with observable, getting an Observable Accessor.
It might seem to be a problematic decision considering
Map
andSet
built-ins, but this is a start to solving it and in practice it's almost never a problem.
There is no built-in state manager, Proton only handles Observables as is. Now creating data flows is your problem :) Joking, it always was, now it's separated from the "framework", so you can use any library that supports Observables. Like the one that is created specially for Proton - Flow.
const regularText = "Static Value"
const observableText = {
i: 0,
get: () => "Initial",
[Symbol.subscribe](next) {
setInterval(() => next("Next: " + this.i++))
}
}
function MyApp() {
return (
<div>{regularText} {observableText}</div>
)
}
This may seem like a lot, but in reality it all will be hidden behind objects.
To implement a state, all you need is this simple snippet.
class State<T> {
private readonly callbacks = new Set<(value: T) => void>()
private value: T
constructor(initialValue: T) { this.value = initialValue }
get(): T { return this.value }
set(value: T): void {
this.value = value
this.dispatch(value)
}
private dispatch(value: T) {
this.callbacks.forEach(callback => callback(value))
}
[Symbol.subscribe](next: (value: T) => void) {
this.callbacks.add(next)
return { unsubscribe: () => void this.callbacks.delete(next) }
}
}
In practice it looks like this
function ProductCard() {
const title = new State("Title")
const image = new State("Image source")
return (
<>
<div className="product-card__title">{title}</div>
<img className="product-card__image" src={image} alt="Preview" />
<input value={title} />
<input value={image} />
</>
)
}
To get started faster with this, try Flow
state library.
Some elements may have binding in two directions, which means that [state updates element] and [element updates state].
This is common for user input elements just like input
and textarea
.
If you're using Flow
library, you can avoid this behavior with readonly
method.
function ProductCard() {
const title = new Flow("Title")
return (
<>
// User inputs won't update the `title`.
<input value={title.readonly()} />
// User inputs WILL update the `title`.
<input value={title} />
</>
)
}
Another built-in support is for Iterable
, any object that has Symbol.iterator
will be treated as an iterable, which will be iterated and inflated.
If it is an Observable Iterable, it will follow changes by iterating again and replacing all previous elements with no reconciliation.
To force reconciling, use built-in helper Proton.Reconcile
.
Learn more about reconciling.
Each element and component has mounted
attribute, which is defined by a Boolean Observable Getter. The inflated element is preserved between mounting and dismounting, it is never reevaluated.
- If
true
, the element is connected (appended) to the document - If
false
, the element is disconnected (removed) from the document
const mounted = State(false)
setTimeout(() => mounted.set(true))
<span mounted={mounted}>
Guard pattern usually refers to a Return/Throw Guard in JavaScript.
However, in Proton it is a predicate, which defines if an object is ok to use. If the valid
guard return false
, normally it is simply skipped.
const plain = { value: 1 }
const guarded = { value: 1, valid(value) { return value > 0 } }
This very mechanism is used to tell if an element should be mounted or unmounted by invoking valid
function.
In case of Observable Guarded object, it will call the guard on each update.
Each JSX attribute can be a Mounting Guard, which means the element is mounted only when every attribute is NOT guarded.
TypeScript: This approach allows type narrowing, so your property is always getting a correct type.
If you're using Flow
, you can use guard
method or built-in predicates like required
.
const className = new Flow("")
const content = new Flow<string | null>(null)
const Component = () => (
<>
<span className={className.guard(it => !it)}>{content.required}</span>
</>
)
As you can see even the children can be guarded. The guard
method can be implemented by your State library or you can create your own utility function do that to cover your special cases.
If you dislike this method, you can implement the way SolidJS does it or your unique way by Extending Default Behavior.
A component example I converted from React and changed a bit for demonstration purposes.
import { Flow, Flowable } from "@denshya/flow"
interface MiniProfileProps {
user: Flowable<User>
}
function MiniProfile(props: MiniProfileProps) {
const user = Flow.from(props.user)
const inputValue = new Flow("")
const inputMounted = new Flow(false)
inputValue.sets(user.$.avatar)
return (
<div className="mini-profile">
<div className="mini-profile__profile">
<button className="mini-profile__letter" mounted={user.$.avatar.isNullish}>
<ColoredLetter letter={user.$.firstName.$[0]} />
</button>
<img className="mini-profile__avatar" src={user.$.avatar.required} alt="avatar" />
<input value={inputValue} mounted={inputMounted} />
<div className="mini-profile__info">
<div className="mini-profile__name">{user.$.firstName} {user.$.lastName.$[0]}.</div>
<div className="mini-profile__email">{user.$.email}</div>
</div>
</div>
<button type="button" on={{ click: () => inputMounted.set(it => !it) }}>
<Icon name="pen" />
</button>
</div>
)
}
The components are not actually components, they are constructors and evaluators for Proton.Component
, this is a heart of every component.
However, it doesn't mean component creates a Proton.Component
, actually it is created before constructor is invoked, but when it's done the component instance is passed as this
argument.
Which opens controls to the APIs of component behavior.
This controls which element is displayed under a component.
function MyView(this: Proton.Component) {
this.view.set(<div>Hello World!</div>)
setTimeout(() => {
this.view.set(<div>I'mg Replaced!</div>)
}, 1000)
}
That's why a component can return nothing - it may set a view via this.view.set
.
return
defines a default
view for a component under this.view.default
property.
Optionally, you can optimize your elements by inflating them beforehand.
function MyView(this: Proton.Component) {
const helloWorldView = this.inflator.inflate(<div>Hello World!</div>)
const replacedView = this.inflator.inflate(<div>I'm Replaced!</div>)
this.view.set(helloWorldView)
setTimeout(() => this.view.set(replacedView), 1_000)
}
Or use Proton.Switch
helper.
Every component can by an async function. That's where ViewAPI
comes in handy.
First we set a loader placeholder to be displayed, then when the promise is resolved the default view will be set.
async function MyView(this: Proton.Component) {
this.view.set(<Loader />)
await new Promise(resolve => setTimeout(resolve, 1_000))
return <div>I'm loaded!</div>
}
Alternatively, you can use AsyncGeneratorFunction
to avoid this
async function MyView() {
yield <Loader />
await new Promise(resolve => setTimeout(resolve, 1_000))
return <div>I'm loaded!</div>
}
But what if want to throw a
Promise
not await it?
Well... Why not.
function MyView(this: Proton.Component) {
this.view.set(<Loader />)
throw new Promise(resolve => setTimeout(resolve, 1_000))
return <div>I'm loaded!</div>
}
Make sure a parent catches it by using suspense
and unsuspense
. This is an experimental API, so don't rely on it too much.
function Parent(this: Proton.Component) {
this.suspense(() => this.view.set(<Loader />))
this.unsuspense(() => this.view.set(this.view.default))
return <div><MyView /></div>
}
Just like React, Proton provides an interface to catch errors thrown in children components.
function Child() { throw new Error("Test") }
function Parent(this: Proton.Component) {
this.catch(thrown => { /* Do something */ })
return <div><Child /></div>
}
But the difference is event handlers. They are caught as well.
function Child(this: Proton.Component) {
// This will catch a event handler error.
this.catch(thrown => { /* Do something */ })
return <button on={{ click: () => { throw new Error("Test") } }} />
}
IMHO, the most satisfying API in Proton. Basically, this API is a Map
that use class constructor as key and its instance as value.
This means you create your contexts by creating classes.
class MyContext { constructor(readonly value: string) {} }
function Child(this: Proton.Component) {
const context = this.context.require(MyContext)
return <span>{context.value}</span>
}
function Parent(this: Proton.Component) {
this.context.provide(new MyContext("Cool"))
return <div><Child /></div>
}
A class that is responsible for creating a view representation held in memory.
This extends JSX attributes. It requires three steps to make it work.
- Declare custom attribute with
jsxAttributes.set
. - Bind attribute(s) to the element, this may override intrinsic attributes.
- Augment
JSX.CustomAttributes
interface to include the custom attribute if you're using TypeScript.
import { WebInflator } from "@denshya/proton"
import { castArray } from "./utils/common"
import { bemFlow } from "./utils/bem"
import { Flowable } from "@denshya/flow"
declare global {
namespace JSX {
interface CustomAttributes {
/** Applies modifiers based `className` and overrides `className` attribute. */
classMods?: { [K in keyof never]: Flowable<unknown> } | Flowable<unknown>[]
}
}
}
function applyCustomAttributes(inflator: WebInflator) {
inflator.jsxAttributes.set("classMods", context => {
if (context.value == null) return
context.bind("className", bem(context.props.className as never, ...castArray(context.value)))
})
}
export default applyCustomAttributes
This kind of customization allows you to override the output of inflate
method.
You can provide inflate instructions to your own structures, lately they can be inserted as part of JSX.
class PriceWebInflator {
constructor(inflator: Inflator) {}
inflate(price: unknown) {
if (price instanceof Price === false) return
const span = document.createElement("span")
span.textContent = price.value + price.sign
return span
}
}
const inflator = new WebInflator
inflator.adapters.add(PriceWebInflator)
function MyApp() {
return <div>{new Price(123, "USD")}</div>
}
const MyAppInflated = inflator.inflate(<MyApp />)
document.getElementById("root").replaceChildren(MyAppInflated)
WebInflator
is a class, it has some public
methods, but it also has several protected
methods, which might be interesting for you.
Currently not implemented, but this is a desired syntax
const array = Observable([{a:1}, {a:2}, {a:3}])
array.set([{a:0}, {a:2}, {a:4}]) // Will trigger list reconciling, which will create 2 new elements, but will **reuse** the one with unchanged key (`{a:2}`).
const list = new Proton.Reconcile(array, { key: it => it.a })
list.key = it => it.b // Assigns a new `key` selector.
<div>{list.map((observableValue, index) => <span>{observableValue} {index}</span>)}</div>
Instead of creating one component and observables to be handled internally, thus making it "Static".
You can make it "Dynamic" by swapping components/elements based on observables passed as props
.
This is extremely useful if you have nested observables.
<span>{Proton.Dynamic(Component, { id: observableId })}</span>
It can be used both for Component View swapping and as a part of any JSX element.
In case of being part of JSX, you should connect ProtonSwitchWebInflator
.
const inflator = new WebInflator
inflator.adapters.add(ProtonSwitchWebInflator)
function SwitchComponent(this: Proton.Component) {
const switcher = new ProtonSwitch({
banned: <span>Banned</span>,
pending: <span>Pending</span>,
default: <span>Loading...</span>
})
switcher.set("banned") // View will change to <span>Banned</span>.
switcher.set("pending") // View will change to <span>Pending</span>.
switcher.set("default") // View will change to <span>Loading...</span>.
switcher.sets(this.view)
return switcher.current.value
}
async function UserProfile() {
const userStatusSwitch = new Proton.Switch({
banned: <Status color="red">Banned</Status>,
pending: <Status color="yellow">Pending</Status>,
default: <Status color="green">Active</Status>
})
const user = await requestCurrentUser()
user.status.sets(userStatusSwitch)
return (
<div>
...
<div>Status: {userStatusSwitch}</div>
...
</div>
)
}
It can be used for dynamic imports, but it is strongly recommended to implement this one in your own way as it's very simple and will give your users a much better UX.
function Lazy(importFactory: () => Promise<unknown>) {
return async function (this: Proton.Component) {
this.view.set(<Loader />)
const Module = await importFactory()
return <Module />
}
}
const UserProfile = Lazy(async () => (await import("pages/user-profile")).default)
<UserProfile />
WebJSXSerializer
serves the purpose of serializing given JSX, Components and Primitives (including adopted).
Currently supports only sync serialization to string, but it is planned to support async components and data streaming.
Report if there are anything uncovered for TypeScript.