Overview
This is a brief overview of how to work with Brainful.
In the examples, many details are omitted to focus on the essentials.
The complete code can be found in the Tutorial section.
Component
Brainful provides two classes for working with components: ComponentPure
and ComponentBase
.
ComponentPure
is a stateless component, while ComponentBase
is a stateful component.
Here’s how to define components.
import { ComponentPure } from "@brainfuljs/brainful"
interface Props {}
@injectable()
export class Button extends ComponentPure<Props> {
constructor() {
super()
}
}
import { ComponentBase } from "@brainfuljs/brainful"
interface Props {}
interface State {}
@injectable()
export class Counter extends ComponentBase<Props, State> {
constructor() {
super()
}
}
Props
The Props
for the ComponentPure
and ComponentBase
components can be of any type.
The Props
types are passed to ComponentPure
and ComponentBase
, and will be accessible in the component as this.props
.
interface Props {
content: string
classes?: string
}
@injectable()
export class Button extends ComponentPure<Props> {
render() {
const template = `
<button class="btn {{classes}}">
{{content}}
</button>
`
return M.render(template, {
content: this.props.content || "",
classes: this.props.classes || "",
})
}
}
Modifying this.props
in a component will not trigger a re-render of that component.
ComponentPure
and ComponentBase
provide the Component.setProps()
function for setting Props
.
Component.setProps()
accepts a function that returns an object containing the Props
.
@injectable()
export class Counter extends ComponentBase<Props, State> {
constructor() {
super()
this.stateSubject = new BehaviorSubject<State>(
fromJS({
children: {
buttonDec: {
component: container.get<Button>(Button).setProps(() => ({
classes: "btn-count btn-dec",
content: "decrement",
})),
},
},
}),
)
}
}
Setting new Props
with Component.setProps()
will not trigger a re-render of the component.
State
The state in ComponentBase
components is defined as a BehaviorSubject
from RxJS.
To efficiently manage state, we use Immutable.
import { fromJS, FromJS } from "immutable"
import { BehaviorSubject, Observable } from "rxjs"
interface State {
counter: number
}
type StateImm = FromJS<State>
@injectable()
export class Counter extends ComponentBase<Props, StateImm> {
public stateSubject: BehaviorSubject<StateImm>
public state: Observable<StateImm>
constructor() {
super()
this.stateSubject = new BehaviorSubject<StateImm>(
fromJS({
count: 0,
}),
)
this.state = this.stateSubject.asObservable()
}
}
To change the state, we call this.stateSubject.next()
with the new value.
Changing the state will trigger the render()
method of the component, and the render()
and mount()
methods of all its child components will also be called.
@injectable()
export class Counter extends ComponentBase<Props, State> {
handleToggleCounter() {
this.stateSubject.next(
this.stateSubject.getValue().updateIn(["count"], (value) => value + 1),
)
}
}
Setting a new state is a signal for Brainful to start rendering the component tree.
The rendering of the tree will occur relative to the component that changed its state.
Render
In components, we define the render()
method, which processes the template and returns a string.
We use Mustache for template processing.
import M from "mustache"
@injectable()
export class Button extends ComponentPure<Props> {
render() {
const template = `
<button class="btn {{classes}}">
{{content}}
</button>
`
return M.render(template, {
content: this.props.content || "",
classes: this.props.classes || "",
})
}
}
import M from "mustache"
@injectable()
export class Counter extends ComponentBase<Props, State> {
render(): string {
const template = `
<div>
<p>{{state.count}}</p>
</div>
`
return M.render(template, {
state: this.stateSubject.getValue().toJS(),
})
}
}
The render()
method should only define the template and handle variables.
Do not modify the state in the render()
method.
There should be no side effects in the render()
method.
Children
To work with child components, we define them in the state of ComponentBase
.
To allow Brainful to traverse our collection of Children
components, we provide an iterator with Component.children()
.
import { childrenIterator } from "../../shared/tools/childrenIterator"
interface State {
children: Record<
string,
{
component: Children
}
>
counter: number
}
1
type StateImm = FromJS<State>
@injectable()
export class Counter extends ComponentBase<Props, StateImm> {
constructor() {
super()
this.stateSubject = new BehaviorSubject<StateImm>(
fromJS({
children: {
buttonDec: {
component: container.get<Button>(Button).setProps(() => ({
classes: "btn-count btn-dec",
content: "decrement",
})),
},
buttonInc: {
component: container.get<Button>(Button).setProps(() => ({
classes: "btn-count btn-inc",
content: "increment",
})),
},
},
counter: 0,
}),
)
}
children(): ChildrenIterator {
return childrenIterator(this.stateSubject)
}
}
import type { Children, ChildrenIterator } from "@brainfuljs/brainful"
import { List, Map } from "immutable"
import { BehaviorSubject } from "rxjs"
export function childrenIterator(
stateSubject: BehaviorSubject<any>,
): ChildrenIterator {
return {
forEach: (cb: (c: Children) => void) => {
const traversal = (c: any) => {
if (Map.isMap(c)) {
cb(c.get("component") as Children)
}
if (List.isList(c)) {
c.forEach((c) => traversal(c))
}
}
stateSubject
.getValue()
.get("children")
.forEach((c: any) => traversal(c))
},
}
}
It is necessary to provide access to child components through an iterator, otherwise, Brainful will not set the current component as their parent and will be unable to mount and unmount child components.
To dynamically create components, we define a factory Component.childrenMap
to obtain new instances of components.
Now we can create and add new components to the state by calling Component.childrenMap.counter()
.
interface State {
children: {
list: {
component: Counter
}[]
}
}
type StateImm = FromJS<State>
@injectable()
export class ListCounter extends ComponentBase<Props, StateImm> {
public childrenMap: { counter: () => { component: ComponentStateful } }
constructor() {
super()
this.childrenMap = {
counter: () => ({
component: container.get<Counter>(Counter),
}),
}
this.stateSubject = new BehaviorSubject<StateImm>(
fromJS({
children: {
list: [
{
component: container.get<Counter>(Counter),
},
],
},
}),
)
this.state = this.stateSubject.asObservable()
}
handleUpdateList() {
this.stateSubject.next(
this.stateSubject
.getValue()
.updateIn(["children", "list"], (list: any) =>
list.push(fromJS(this.childrenMap.counter())),
),
)
}
}
Brainful generates Component.id
and Component.host
, which are stable DOM elements between renders.
Brainful searches for its Component.id
within Component.parent.host
using the data-b-key
attributes to render the component.
Therefore, to output child components in the template, you need to set the data-b-key
attribute equal to Component.id
on
the necessary element for rendering the child component.
@injectable()
export class Counter extends ComponentBase<Props, State> {
render(): string {
const template = `
<div>
<p>{{state.count}}</p>
<div data-b-key="{{state.children.buttonDec.component.id}}"></div>
<div data-b-key="{{state.children.buttonInc.component.id}}"></div>
</div>
`
return M.render(template, {
state: this.stateSubject.getValue().toJS(),
})
}
}
Events
For event handling, we use delegation and define listeners on the Components.host
DOM element.
Listener definitions are available at the time of component creation in its constructor()
.
We filter event.target
to find the desired element.
To manage unsubscribing, we create a Subject
from RxJS.
import { catchError, fromEvent, of, Subject, takeUntil, tap } from "rxjs"
import { delegate } from "../../shared/tools/delegate"
import * as R from "ramda"
@injectable()
export class Counter extends ComponentBase<Props, State> {
public unsubscribe: Subject<void>
constructor() {
super()
this.unsubscribe = new Subject<void>()
this.handleToggleCounter()
}
onDestroy() {
this.unsubscribe.next()
this.unsubscribe.complete()
}
handleToggleCounter() {
fromEvent(this.host, "click")
.pipe(
takeUntil(this.unsubscribe),
delegate("btn-count"),
tap((event) => {
R.pipe(
R.ifElse(
() => (event.target as HTMLElement).classList.contains("btn-dec"),
() => R.dec,
() => R.inc,
),
(cb) =>
this.stateSubject.next(
this.stateSubject
.getValue()
.updateIn(["count"], (value) => cb(value as number)),
),
)()
}),
catchError((error) => {
this.errorHandler.handle(error)
return of(error)
}),
)
.subscribe()
}
}
import { filter } from "rxjs"
export function delegate(selector: string) {
return filter((event: Event) => {
const target = event.target as HTMLElement
return target.classList.contains(selector)
})
}
Only Components.host
is stable between re-renders, or you can use listeners on the document.
Destroy
We do not need to manually destroy components, even though Component.destroy()
is available.
We simply need to monitor the state of parent components and define Component.id
in the parent's template.
If a child component is no longer needed, do not render its Component.id
in the template with data-b-key
, and remove it from the state of the parent ComponentBase
.
Each child component receives Component.parent
upon creation and knows about its parent.
Any change in the parent's state signals its child components to check their Component.id
in Component.parent.host
.
If no element with the corresponding data-b-key
is found, this signals the component to be destroyed, and Brainful will call Component.destroy()
.
You may not want to destroy the component and its state but only temporarily unmount it.
In that case, set Component.setSlick(() => true)
for it.
@injectable()
export class StepperWithSaveState extends ComponentBase<Props, State> {
constructor() {
super()
this.stateSubject = new BehaviorSubject<State>(
fromJS({
children: {
buttonPrev: {
component: container.get<Button>(Button).setProps(() => ({
classes: "btn-step btn-step-prev",
content: "prev",
})),
},
buttonNext: {
component: container.get<Button>(Button).setProps(() => ({
classes: "btn-step btn-step-next",
content: "next",
})),
},
stepFirst: {
component: container.get<Counter>(Counter).setSlick(() => true),
},
stepSecond: {
component: container.get<Counter>(Counter).setSlick(() => true),
},
},
active: "stepFirst",
stepFirst: true,
stepSecond: false,
}),
)
}
}
Lifecycle
constructor()
- When creating a component, we can start acting here, for example, by initiating event listeners.onMount()
- This method should be used sparingly, as it is often called when the parent's state changes.onUpdate()
- This method is invoked when the component changes its state.onDestroy()
- This method is called when Brainful destroys the component.render()
- We should not perform any actions here other than defining and processing the template.
When a component changes its state, render()
and mount()
of all child components in the subtree will be triggered.
Brainful will call onDestroy()
when you remove the data-b-key
bindings from the parent's template and change its state.
Root
To initiate the rendering of the component tree, it must be done by the root component.
The RootCreator
class creates a pseudo ComponentBase
, which triggers the rendering of our root component.
We need to provide the root DOM element and a function to obtain the root component to the rootCreator.render()
method.
import "reflect-metadata"
import { TYPES as TYPES_BRAINFUL, type RootRender } from "@brainfuljs/brainful"
import { container } from "src/app/compositionRoot/container.ts"
import { List } from "src/features/List"
const rootCreator = container.get<RootRender>(TYPES_BRAINFUL.RootCreator)
const rootElement = document.getElementById("root-list")
if (rootElement) {
rootCreator.render(rootElement, () => container.get<List>(List))
} else {
throw Error("Not found root element")
}
Container
We use the Inversify container for dependency management.
We mark components with the @injectable
decorator for automatic injection of these classes.
export interface ErrorHandler {
handle(error: Error): void
}
import { injectable } from "inversify"
import { ErrorHandler } from "../../interfaces"
@injectable()
export class ErrorService implements ErrorHandler {
handle(error: Error): void {
console.log(error)
}
}
export const TYPES = {
ErrorService: Symbol.for("service.ErrorService"),
} as const
import { container as containerBrainful } from "@brainfuljs/brainful"
import { Container } from "inversify"
import { ErrorService } from "../../domain/Error"
import { ErrorHandler } from "../../interfaces"
import { TYPES } from "./types.ts"
const container = new Container({
autoBindInjectable: true,
skipBaseClassChecks: true,
})
container.parent = containerBrainful
container
.bind<ErrorHandler>(TYPES.ErrorService)
.to(ErrorService)
.inSingletonScope()
export { container }
import { TYPES } from "../../app/compositionRoot/types"
import type { ErrorHandler } from "../../interfaces"
@injectable()
export class Counter extends ComponentBase<Props, State> {
constructor(@inject(TYPES.ErrorService) public errorHandler: ErrorHandler) {
super()
}
}
Configuration
Using the container, we can override some Brainful classes.
import { IdGenerator } from "@brainfuljs/brainful"
import { nanoid } from "nanoid/non-secure"
@injectable()
export class ComponentId implements IdGenerator {
generate(): string {
return `b-${nanoid(8)}`
}
}
import {
container as containerBrainful,
type IdGenerator,
TYPES as TYPES_BRAINFUL,
} from "@brainfuljs/brainful"
import { Container } from "inversify"
import { ComponentId } from "../configuration"
containerBrainful
.rebind<IdGenerator>(TYPES_BRAINFUL.ComponentId)
.to(ComponentId)
const container = new Container({
autoBindInjectable: true,
skipBaseClassChecks: true,
})
container.parent = containerBrainful
export { container }
Conclusion
- We handle reactivity in components using RxJS.
- We effectively manage state with Immutable.
- We control event handling.
- We process templates with Mustache.
- We delegate dependency management to the container.
- We can override implementations of certain Brainful classes.
- Brainful is responsible for mounting and unmounting components.