@nanotree/core
v0.0.1-rc.6
Published
DOM generation built around nanostores
Downloads
1
Readme
NanoTree
NanoTree is a lightweight, type-safe, and reactive web UI library built with TypeScript.
The library uses Nanostores for state management and provides utilities for DOM manipulation, components creation, and event handling.
Installation
npm install @nanotree/core nanostores
yarn add @nanotree/core nanostores
Basic usage
At its core, Nanotree offer a wrapper around document.createElement
which adds the ability to
bind a nanostores
atom to either an element property, or one of its nodes.
Creating a reactive element is done through the element
helper exported by @nanotree/core
:
const myDiv = element('div')
You can then customize your element through chainable modifier methods, allowing you to assign properties, bind event handlers, or manipulate children:
myDiv
// Set a given prop
.prop('id', 'root')
// Set one or more props in one go
.props({ className: 'section' })
// Append a children
.node(element('span').node('Text content'))
// Append multiple children
.nodes([
'More content',
element('strong').node('Strong content'),
element('button')
// Bind one event listener
.event('click', someClickListener)
// Bind multiple events in one go
.events({
mouseenter: someHoverStartListener,
mouseleave: someHoverEndListener,
}),
])
Binding atoms
You can use nanostores
atoms as the value for any property you set, or for a text node:
import { atom } from 'nanostores'
import { element } from '@nanotree/core'
const $value = atom('')
const boundValue = element('section').nodes([
element('input')
.prop('value', $value)
.event('input', (e) => $value.set(e.currentTarget.value)),
element('p').node($value),
])
// The rendered p content will be synced with the input value
Creating reusable components
NanoTree is really permissive in terms of what can be passed as child nodes:
- Any JS primitive type is valid (
string
,number
andboolean
), and will get stringified, - Any NanoTree Element is valid,
- Any
ReadableAtom
wrapping a JS primitive or a NanoTree Element, - Any array containing valid children,
render
/cleanup
pairs (explained later), wrapped or not in aReadableAtom
.
Thanks to this, any function can be used as reusable component as long as it returns valid children.
Since the reactivity is handled through nanostores
atoms, the main body of a component function is
not called multiple times, only once on mount. You have complete control on when the function is invoked,
and you can take advantage of its closure to handle internal state:
import { atom } from 'nanostores'
import { element } from '@nanotree/core'
const SimpleCounter = (step: number = 1, defaultValue: number = 0) => {
const $value = atom(defaultValue)
const increase = () => $value.set($value.get() + step)
const decrease = () => $value.set($value.get() - step)
return element('div').nodes([
element('button').event('click', decrease).node('Decrease'),
element('span').node($value),
element('button').event('click', increase).node('Increase'),
])
}
const myApp = element('main').nodes([
element('p').node('First Counter:'),
SimpleCounter(),
element('p').node('2-by-2 Counter:'),
SimpleCounter(2, 10),
])
Creating side effects with a clean-up step
Sometimes, our components might want to register side effects that should run while the component is mounted, and stop when unmounted.
To do so, instead of directly returning children, you can return a render
/cleanup
pair. The render
function
will be run when the component mounts, and should return the nodes to render; and the cleanup
function will be called
on component unmount, and can take care of cleaning up any side effects.
import { atom } from 'nanostores'
import { element } from '@nanotree/core'
const SimpleCounter = (step: number = 1, defaultValue: number = 0) => {
const $value = atom(defaultValue)
const increase = () => $value.set($value.get() + step)
const decrease = () => $value.set($value.get() - step)
let unsubscribe
return {
render() {
unsubscribe = $value.listen((value) =>
console.log('New counter value:', value),
)
return element('div').nodes([
element('button').event('click', decrease).node('Decrease'),
element('span').node($value),
element('button').event('click', increase).node('Increase'),
])
},
cleanup() {
unsubscribe()
},
}
}
Helpers
NanoTree exports some useful helpers to reduce boilerplate.
HTMLElement shortcuts
You can import the tree
object from @nanotree/core
, and use it to access shortcuts for creating HTML elements:
import { atom } from 'nanostores'
import { tree } from '@nanotree/core'
const $value = atom('')
const boundValue = tree.section.nodes([
tree.input
.prop('value', $value)
.event('input', (e) => $value.set(e.currentTarget.value)),
tree.p.node($value),
])
Component factory
You can import the component
method from @nanotree/core
, and use it to wrap your components to get some
helpful helpers. The component
factory hides away the complexity of maintaining your own render
/cleanup
pairs:
import { atom } from 'nanostores'
import { tree, component } from '@nanotree/core'
const SimpleCounter = component<{
step?: number
defaultValue?: number
}>(({ props: { step = 1, defaultValue = 0 }, effect }) => {
const $value = atom(defaultValue)
const increase = () => $value.set($value.get() + step)
const decrease = () => $value.set($value.get() - step)
effect(() => {
const unsubscribe = $value.listen((value) =>
console.log('New counter value:', value),
)
return () => unsubscribe()
})
return tree.div.nodes([
tree.button.event('click', decrease).node('Decrease'),
tree.span.node($value),
tree.button.event('click', increase).node('Increase'),
])
})
Passing props to a component from the factory
The component
factory returns a standard function interface, which receives options as a first argument,
and children nodes as second argument.
However, the returned function also contains chainable methods to make passing props, children, or events easier:
const myApp = tree.main.node(SimpleCounter.props({ step: 2, defaultValue: 10 }))
The chainable methods are the same as for elements: props
to set props, node
/nodes
to handle children,
and event
/events
to bind events.
prop
is not available as, contrarily to HTML elements, we can't know in advance if all props are optional, and therefore
it might not make sense to pass partial props. To the same effect, the props
function requires all required props to
be set, it does not take a partial representation.
Emitting and subscribing to events
When calling a component it's possible to pass it a map of event name to event listeners.
An additional helper function is accessible in the component function first argument: emit
.
This can be used to emit an event that can be listened to by the parent:
import { component } from '@nanotree/core'
const myEmittingComponent = component<
{},
{ click: CustomEvent<{ detail: any }> }
>(({ emit }) => {
return tree.button.event('click', () =>
emit(new CustomEvent('click', { detail: 'The button was clicked' })),
)
})
const myReceivingComponent = component(() => {
return myEmittingComponent.events({
click: (event) => console.log(event.detail),
})
})
JSX support
If you prefer using JSX rather than raw JS for building your UIs, NanoTree got you covered too!
The tree
export from @nanotree/core
double-duties as our JSX factory (and JSX namespace if you're using TypeScript).
You can either set your jsxFactory
config entry to tree
in your build tool, or use it on a per-file basis by appending
the jsx-transform comments:
// @jsx tree
// @jsxFrag tree.Fragment
Passing props, children, and binding events
NanoTree uses JSX slightly differently from React and such. This is on purpose: it's a way to both simplify our internal logic, and clearly mark that the React ecosystem cannot be consumed in a NanoTree app. Indeed, the reactive approach used by NanoTree through atoms makes it impossible to create a compatibility layer to React.
As such, we opted not to support props on JSX tags directly. Instead, props need to be passed down as a JS object,
through the special $props
JSX prop. The same way, events are still passed as a map of event name to listener,
through the special $events
JSX prop. Children are passed as JSX children as usual:
import { tree } from '@nanotree/core'
import { SimpleCounter } from './simple-counter'
export const App = () => {
return (
<main>
<SimpleCounter $props={{ step: 2, defaultValue: 10 }} />
</main>
)
}
Because of this, components usable through JSX must be created through the component
factory. Direct function calls
are still supported, but not as JSX tag:
import { tree } from '@nanotree/core'
import { SimpleCounter } from './simple-counter'
const Title = (message: string) => (
<h1>This is a direct function, not a component: {message}</h1>
)
export const App = () => {
return (
<main>
{/* This will fail: */}
<Title $props="Hello world" />
{/* This will work: */}
{Title('Hello world')}
<SimpleCounter $props={{ step: 2, defaultValue: 10 }} />
</main>
)
}
Configuring JSX with Typescript
In order for Typescript to understand that JSX is not the standard React JSX, but consumes NanoTree instead, you need to update some fields in your TS Config:
{
compilerOptions: {
jsx: 'react', // Use the old JSX transform
jsxFactory: 'tree', // Use `tree` as the JSX factory
jsxFragmentFactory: 'tree.Fragment', // Use `tree.Fragment` to handle JSX fragments
reactNamespace: 'tree', // Use `tree` as JSX namespace
},
}