Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Component docs update: an effective compromise re: state & syntactic variance #2294

Merged
merged 9 commits into from
Nov 15, 2018
279 changes: 123 additions & 156 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@

- [Structure](#structure)
- [Lifecycle methods](#lifecycle-methods)
- [Syntactic variants](#syntactic-variants)
- [Passing data to components](#passing-data-to-components)
- [State](#state)
- [Closure component state](#closure-component-state)
- [POJO component state](#pojo-component-state)
- [ES6 Classes](#es6-classes)
- [Class component state](#class-component-state)
- [Avoid anti-patterns](#avoid-anti-patterns)

### Structure

Components are a mechanism to encapsulate parts of a view to make code easier to organize and/or reuse.

Any Javascript object that has a view method is a Mithril component. Components can be consumed via the [`m()`](hyperscript.md) utility:
Any Javascript object that has a `view` method is a Mithril component. Components can be consumed via the [`m()`](hyperscript.md) utility:

```javascript
var Example = {
view: function() {
view: function(vnode) {
return m("div", "Hello")
}
}
Expand All @@ -27,31 +31,9 @@ m(Example)

---

### Passing data to components

Data can be passed to component instances by passing an `attrs` object as the second parameter in the hyperscript function:

```javascript
m(Example, {name: "Floyd"})
```

This data can be accessed in the component's view or lifecycle methods via the `vnode.attrs`:

```javascript
var Example = {
view: function (vnode) {
return m("div", "Hello, " + vnode.attrs.name)
}
}
```

NOTE: Lifecycle methods can also be provided via the `attrs` object, so you should avoid using the lifecycle method names for your own callbacks as they would also be invoked by Mithril. Use lifecycle methods in `attrs` only when you specifically wish to create lifecycle hooks.

---

### Lifecycle methods

Components can have the same [lifecycle methods](lifecycle-methods.md) as virtual DOM nodes: `oninit`, `oncreate`, `onupdate`, `onbeforeremove`, `onremove` and `onbeforeupdate`.
Components can have the same [lifecycle methods](lifecycle-methods.md) as virtual DOM nodes. Note that `vnode` is passed as an argument to each lifecycle method, as well as to `view` (with the _previous_ vnode passed additionally to `onbeforeupdate`):

```javascript
var ComponentWithHooks = {
Expand All @@ -61,7 +43,7 @@ var ComponentWithHooks = {
oncreate: function(vnode) {
console.log("DOM created")
},
onbeforeupdate: function(vnode, old) {
onbeforeupdate: function(newVnode, oldVnode) {
return true
},
onupdate: function(vnode) {
Expand All @@ -86,7 +68,7 @@ var ComponentWithHooks = {
Like other types of virtual DOM nodes, components may have additional lifecycle methods defined when consumed as vnode types.

```javascript
function initialize() {
function initialize(vnode) {
console.log("initialized as vnode")
}

Expand All @@ -101,127 +83,54 @@ To learn more about lifecycle methods, [see the lifecycle methods page](lifecycl

---

### Syntactic variants

#### Closure components

One of the easiest ways to manage state in a component is with a closure. A "closure component" is one that returns an object with a view function and optionally other lifecycle hooks. It has the ability to manage instance state within the body of the outer function.

```javascript
function ClosureComponent(initialVnode) {
// Each instance of this component has its own instance of `kind`
var kind = "closure component"

return {
view: function(vnode) {
return m("div", "Hello from a " + kind)
},
oncreate: function(vnode) {
console.log("We've created a " + kind)
}
}
}
```

The returned object must hold a `view` function, used to get the tree to render.

They can be consumed in the same way regular components can.

```javascript
// EXAMPLE: via m.render
m.render(document.body, m(ClosureComponent))

// EXAMPLE: via m.mount
m.mount(document.body, ClosureComponent)

// EXAMPLE: via m.route
m.route(document.body, "/", {
"/": ClosureComponent
})

// EXAMPLE: component composition
function AnotherClosureComponent() {
return {
view: function() {
return m("main",
m(ClosureComponent)
)
}
}
}
```

If a component does *not* have state then you should opt for the simpler POJO component to avoid the additional overhead and boilerplate of the closure.

#### ES6 classes
### Passing data to components

Components can also be written using ES6 class syntax:
Data can be passed to component instances by passing an `attrs` object as the second parameter in the hyperscript function:

```javascript
class ES6ClassComponent {
constructor(vnode) {
this.kind = "ES6 class"
}
view() {
return m("div", `Hello from an ${this.kind}`)
}
oncreate() {
console.log(`A ${this.kind} component was created`)
}
}
m(Example, {name: "Floyd"})
```

Component classes must define a `view()` method, detected via `.prototype.view`, to get the tree to render.

They can be consumed in the same way regular components can.
This data can be accessed in the component's view or lifecycle methods via the `vnode.attrs`:

```javascript
// EXAMPLE: via m.render
m.render(document.body, m(ES6ClassComponent))

// EXAMPLE: via m.mount
m.mount(document.body, ES6ClassComponent)

// EXAMPLE: via m.route
m.route(document.body, "/", {
"/": ES6ClassComponent
})

// EXAMPLE: component composition
class AnotherES6ClassComponent {
view() {
return m("main", [
m(ES6ClassComponent)
])
var Example = {
view: function (vnode) {
return m("div", "Hello, " + vnode.attrs.name)
}
}
```

#### Mixing component kinds

Components can be freely mixed. A class component can have closure or POJO components as children, etc...
NOTE: Lifecycle methods can also be defined in the `attrs` object, so you should avoid using their names for your own callbacks as they would also be invoked by Mithril itself. Use them in `attrs` only when you specifically wish to use them as lifecycle methods.

---

### State

Like all virtual DOM nodes, component vnodes can have state. Component state is useful for supporting object-oriented architectures, for encapsulation and for separation of concerns.

Note that unlike many other frameworks, component state does *not* trigger [redraws](autoredraw.md) or DOM updates. Instead, redraws are performed when event handlers fire, when HTTP requests made by [m.request](request.md) complete or when the browser navigates to different routes. Mithril's component state mechanisms simply exist as a convenience for applications.
Note that unlike many other frameworks, mutating component state does *not* trigger [redraws](autoredraw.md) or DOM updates. Instead, redraws are performed when event handlers fire, when HTTP requests made by [m.request](request.md) complete or when the browser navigates to different routes. Mithril's component state mechanisms simply exist as a convenience for applications.

If a state change occurs that is not as a result of any of the above conditions (e.g. after a `setTimeout`), then you can use `m.redraw()` to trigger a redraw manually.

#### Closure Component State

With a closure component state can simply be maintained by variables that are declared within the outer function. For example:
In the above examples, each component is defined as a POJO (Plain Old Javascript Object), which is used by Mithril internally as the prototype for that component's instances. It's possible to use component state with a POJO (as we'll discuss below), but it's not the cleanest or simplest approach. For that we'll use a **_closure component_**, which is simply a wrapper function which _returns_ a POJO component instance, which in turn carries its own, closed-over scope.

With a closure component, state can simply be maintained by variables that are declared within the outer function:

```javascript
function ComponentWithState() {
// Variables that hold component state
function ComponentWithState(initialVnode) {
// Component state variable, unique to each instance
var count = 0

// POJO component instance: any object with a
// view function which returns a vnode
return {
view: function() {
oninit: function(vnode){
console.log("init a closure component")
},
view: function(vnode) {
return m("div",
m("p", "Count: " + count),
m("button", {
Expand All @@ -238,7 +147,7 @@ function ComponentWithState() {
Any functions declared within the closure also have access to its state variables.

```javascript
function ComponentWithState() {
function ComponentWithState(initialVnode) {
var count = 0

function increment() {
Expand All @@ -250,7 +159,7 @@ function ComponentWithState() {
}

return {
view: function() {
view: function(vnode) {
return m("div",
m("p", "Count: " + count),
m("button", {
Expand All @@ -265,48 +174,22 @@ function ComponentWithState() {
}
```

A big advantage of closure components is that we don't need to worry about binding `this` when attaching event handler callbacks. In fact `this` is never used at all and we never have to think about `this` context ambiguities.
Closure components are consumed in the same way as POJOs, e.g. `m(ComponentWithState, { passedData: ... })`.

#### Class Component State
A big advantage of closure components is that we don't need to worry about binding `this` when attaching event handler callbacks. In fact `this` is never used at all and we never have to think about `this` context ambiguities.

With classes, state can be managed by class instance properties and methods. For example:

```javascript
class ComponentWithState() {
constructor() {
this.count = 0
}
increment() {
this.count += 1
}
decrement() {
this.count -= 1
}
view() {
return m("div",
m("p", "Count: " + count),
m("button", {
onclick: () => {this.increment()}
}, "Increment"),
m("button", {
onclick: () => {this.decrement()}
}, "Decrement")
)
}
}
```

Note that we must wrap the event callbacks in arrow functions so that the `this` context is preserved correctly.
---

#### POJO Component State

For POJO components the state of a component can be accessed three ways: as a blueprint at initialization, via `vnode.state` and via the `this` keyword in component methods.
It is generally accepted that a component with state that must be managed is best expressed as a closure. If, however, you have reason to manage state in a POJO, the state of a component can be accessed in three ways: as a blueprint at initialization, via `vnode.state` and via the `this` keyword in component methods.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like @pygy and maybe @StephanHoyer to chime in on this one. I'd prefer to not introduce this strong of an opinion against a particular supported component type when our opinion is already pretty clearly implied through the broad use of closure components instead. Also, [citation needed] for the "It is generally accepted that ..." part - I'm not aware of anyone who's done a survey to verify that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something shorter and less contentious:

Whereas state management in closure components is a simple matter of scoped reference, in the case of a component defined as an object,

?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm great with others chiming in, but I strongly disagree that this is a "strong...opinion against" the use of component state in POJOs. This is a strong advocacy for using closures ("best expressed as..."), but there's nothing negative suggested to the contrary, nor are there any of the warning flags we've been joking about on gitter.

I believe this is the kind of confident opinion which our docs are sorely lacking, TBF. This is proper guidance, essentially in the same form which we all use ad nauseam on gitter when someone asks.

Scenario: A random person pops in and says, "I have a component which needs to handle some internal state. What's the best approach?"

My hypothesis: About ten people — @isiahmeadows and @barneycarroll included — will jump in and recommend a closure (within minutes, if not moments).

Of course there's been no survey. But the easily-verified evidence on our gitter channel, from those in the community who are most engaged with the framework, speaks volumes.

I think the docs should have the courage of our convictions, rather than strive to achieve some sort of consensus. It's a form of design/write/code by committee which is too broad and never ends well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better yet, you could recommend that POJO components should be used for stateless components. Something like this:

It's generally recommended that POJO components should be stateless. If, however, you need to access and/or modify a POJO component's state, it can be accessed in three ways: as a blueprint at initialization, via vnode.state, and via the this keyword in component methods.

This way, it's a little more positive and it doesn't discourage the common case of embedding read-only data within the component itself, but it more clearly recommends when it's most useful.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also wouldn't mind classes being recommended for TS+JSX users (thanks to microsoft/TypeScript#21699 and microsoft/TypeScript#14789).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...as for TS + JSX, I'm not qualified to talk about it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CreaturesInUnitards Maybe stateful relative to the DOM, but I'm talking stateful relative to the component itself. If your DOM integration only requires something like $(vnode.dom).plugin({...}) and you can just update it with, say, $(vnode.dom).plugin("action") (this is the case with Bootstrap, BTW), then you don't need to care about vnode.state.

Also, not all hooks have to be stateful:

  • onbeforeupdate serves the secondary purpose of allowing you to diff attributes and cancel updates.
  • onbeforeremove serves to let you do things like CSS transitions.
  • view is obvious.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with @CreaturesInUnitards proposal. As we now move away from POJO and class components (which I strongly support, although I currently only use POJOs for historic reasons), it might be ok to make POJO-Components less prominent.

As stated on Gitter, I'm against the notion of stateless components and the relation to POJO Components.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. I'm personally a little uncomfortable with the wording, but I'll go with it if everyone else is okay with it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok great, let's merge this.


#### At initialization

For POJO components, the component object is the prototype of each component instance, so any property defined on the component object will be accessible as a property of `vnode.state`. This allows simple state initialization.
For POJO components, the component object is the prototype of each component instance, so any property defined on the component object will be accessible as a property of `vnode.state`. This allows simple "blueprint" state initialization.

In the example below, `data` is a property of the `ComponentWithInitialState` component's state object.
In the example below, `data` becomes a property of the `ComponentWithInitialState` component's `vnode.state` object.

```javascript
var ComponentWithInitialState = {
Expand All @@ -324,7 +207,7 @@ m(ComponentWithInitialState)

#### Via vnode.state

State can also be accessed via the `vnode.state` property, which is available to all lifecycle methods as well as the `view` method of a component.
As you can see, state can also be accessed via the `vnode.state` property, which is available to all lifecycle methods as well as the `view` method of a component.

```javascript
var ComponentWithDynamicState = {
Expand Down Expand Up @@ -364,6 +247,90 @@ m(ComponentUsingThis, {text: "Hello"})

Be aware that when using ES5 functions, the value of `this` in nested anonymous functions is not the component instance. There are two recommended ways to get around this Javascript limitation, use ES6 arrow functions, or if ES6 is not available, use `vnode.state`.

---

### ES6 classes

If it suits your needs (like in object-oriented projects), components can also be written using ES6 class syntax:

```javascript
class ES6ClassComponent {
constructor(vnode) {
this.kind = "ES6 class"
}
view() {
return m("div", `Hello from an ${this.kind}`)
}
oncreate() {
console.log(`A ${this.kind} component was created`)
}
}
```

Component classes must define a `view()` method, detected via `.prototype.view`, to get the tree to render.

They can be consumed in the same way regular components can.

```javascript
// EXAMPLE: via m.render
m.render(document.body, m(ES6ClassComponent))

// EXAMPLE: via m.mount
m.mount(document.body, ES6ClassComponent)

// EXAMPLE: via m.route
m.route(document.body, "/", {
"/": ES6ClassComponent
})

// EXAMPLE: component composition
class AnotherES6ClassComponent {
view() {
return m("main", [
m(ES6ClassComponent)
])
}
}
```

#### Class Component State

With classes, state can be managed by class instance properties and methods, and accessed via `this`:

```javascript
class ComponentWithState {
constructor(vnode) {
this.count = 0
}
increment() {
this.count += 1
}
decrement() {
this.count -= 1
}
view() {
return m("div",
m("p", "Count: " + count),
m("button", {
onclick: () => {this.increment()}
}, "Increment"),
m("button", {
onclick: () => {this.decrement()}
}, "Decrement")
)
}
}
```

Note that we must wrap the event callbacks in arrow functions so that the `this` context is preserved correctly.

---

### Mixing component kinds

Components can be freely mixed. A class component can have closure or POJO components as children, etc...


---

### Avoid anti-patterns
Expand Down