levand / quiescent

A lightweight ClojureScript abstraction over ReactJS
Eclipse Public License 1.0
613 stars 46 forks source link

How would one implement an idiomatic `SortableTable` or `Hidable` component ? #2

Closed Raynos closed 10 years ago

Raynos commented 10 years ago

om , reagent and React would recommend and allow you to implement a stateful component that can close over internal render only state.

For example a Hidable component might implement or emulate a HTML5 <details> element and close over a visible boolean which it manages internally without forcing you to place said boolean on your top level data structure.

For example a SortableTable component might implement the notion of sorting a table based on a column and close over a currentSortingColumn index variable and a currentSortDirection boolean which it manages internally without forcing you to place those pieces of data on your top level data structure.

More importantly, even if those pieces of state were placed on the main application state, how would the components mutate or change the main application state idiomatically, without the components being tied to your state / event system, i.e. om cursors or core.async channels.

levand commented 10 years ago

Good questions.

Regarding the Hideable: Yes, if using only Quiescent components, Quiescent would require that the fact that the component is hidden or not be present in the value used to render the component. Note that it doesn't have to be simplistic as a {:hidden true} value, however; it is perfectly legitimate to have the renderer invoke view-specific code that decides whether an item is hidden or not based the value provided. For example, the TodoMVC example does exactly this with its filtering mechanism; the view state only specifies what filter is present, and the rendering code compares that with each item to see if it should be hidden or visible.

The same goes for a SortableTable; the current sort column and direction would need to be specified somehow in the value used to render. Note that in many cases, this is a good thing; it means it is possible to share and persist UI states across sessions (if you want).

The tradeoff is, of course, that it does make it more difficult to write a fully encapsulated UI widget that provides stateful functionality while remaining agnostic to its location. If you rely heavily on such components, Quiescent might not be the library for you. I can, however, think of several approaches:

  1. You could allocate a top-level slot in your application state data structure for the exclusive use of encapsulated widgets, and write your widgets against that.
  2. You can always write a component in raw ReactJS using interop, and drop it in to your render tree. Quiescent components render non-Quiescent child components just fine.
  3. Part of your widget's API could be to define mapping functions that specify how/where to store its internal state (via callback functions, perhaps.) This offloads some of the work on the consumer but maintains the full Quiescent application model.

Your final point is correct. In the Quiescent model, for a component to change anything, it (or, more precisely, a handler function that it defines) must somehow reach back out and update the higher-level state and cause a re-render. Exactly how that works is up to you; Quiescent specifically does not impose any particular technique there. You could pass in a cursor, a-la Om, or core.async channels, like I do in the TodoMVC application. (You could also swap an atom directly in the handler function. But ew, gross.)

My recommendation is that you think of your application's handler functions not as getters and setters for your applications state, but as inputs for your system:

A diagram

Raynos commented 10 years ago

@levand this means that you would need to do something like

render(data, channels) and pass the data to render and a channel into a SortableTableComponent.

The problem now comes that the SortableTableComponent actually owns a set of functions that loop over the channels and mutate the correct state.

i.e. the channels that get triggered when a user clicks the headers should mutate or change some meta data about sort orientation. The user shouldn't have to re-implement that code.

If a component could encapsulate it's own state, it could choose to manage atoms & channels or manage om style cursors. It would mean the component is portable, i.e. the code that reads from channels and mutates state is not hard coupled to the high level decision of how the application represents its model.

At best the user of a re-usable component has to buy into the channels + use my documented data structure decision for the entire application that the component author made and would have to use two sets of functions, one in it's rendering logic and one it's channel reading logic. This seems very tedious.

levand commented 10 years ago

Well, you could create less coupled abstraction point by providing a render function of the form (render data get-state set-state) where get-state and set-state are functions over the data that may be freely invoked by the component to store and retrieve its "internal" state, and are implemented by the application in whatever way makes sense for its particular model.

That would require some additional work for users of the component; however, even that could probably be worthwhile. Especially, if all stateful components used the same idiom, they could even be passed the same functions.

Or, as I said, the right thing might be to fall back to a non-Quiescent component if you really want transient local state.

Regarding your final point about using two sets of functions - I'm not sure I understand how that's different from any other system. Logically you had to do the same work anyway (rendering and updating), Quiescent just encourages you to handle those as distinctly different concerns. And, its true that using core.async channels does require you to implement both ends (producer and consumer), but that's inherent in its value proposition (coordinating decoupled processes). If you don't want that you probably just shouldn't use channels.

Raynos commented 10 years ago

I realized the problem with transient local state is that it breaks referential transparency of higher order pure components.

You can't embed one of these stateful components as a child of a referentially transparent component because you wont re-render it unless it's state has changed.

Even with (render data get-state set-state) you can't apply the optimization of saying the first argument hasn't changed so lets skip rendering, you need to know whether the transient state has changed.

React.js gets around this by doing subtree re-rendering, i.e. it an re-render a tree from any point, not just top down.

One truly needs to make a trade off between the modularity of transient state and the referential transparency of stateless render.

levand commented 10 years ago

I am working on an example that I think will address your concerns.

Raynos commented 10 years ago

@levand I'm more and more convinced that transient state is a terrible idea. and that I should simply build components around render(state, channels)

One thing I'm really interested is benchmarks of React v om v quiescent v reagent

levand commented 10 years ago

Well, I think there is a legitimate tradeoff there. The point you make about encapsulation is real; it definitely is more work to design a good, generic, reusable component when rendering using only top-down data. Personally, though, I think that's still the right way to go.

I've been thinking about some ideas for benchmarks; will definitely publish some when I can.

geraldodev commented 10 years ago

@levand excited waiting the example you're making.

You redefined the word less with this wrapper. Sincerely hope it flourish.

Regards,

Geraldo

geraldodev commented 10 years ago

@levand Could you explain why do you always put values on components on todomvc with (go (>! )) instead of (put!) ? Considering you are not in a go block context when rendering and most operations are not async your main go block would not have the risk of dying if you had used put!. Is that correct ?

Raynos commented 10 years ago

@levand did you ever make an example for this ?

levand commented 10 years ago

I haven't had time to make a good (public) example yet, though I'm using Quiescent pretty intensively for a client project so it does have attention being devoted to it.

I have some ideas for more open-source examples, but am not sure when I will have time to publish them.

I'm closing this issue for now as it doesn't have anything immediately or specifically actionable - watch this repo and my twitter feed for when I do get a chance to put examples out there.

Raynos commented 10 years ago

@levand

I actually started authoring a react-style framework that bans local state. I don't think there is a need for it and will just not use it.

The pattern I've been using is

var state = mercury.hash({
  sortTable: SortableTable(...).state
})

function render(state) {
  return h('div', [
    RenderSortableTable(state.sortTable, ...)
  ])
}

i.e. you embed a module's state in your global state atom and then thread it's state to it in the rendering call.