adamhaile / surplus

High performance JSX web views for S.js applications
636 stars 26 forks source link

Question: Best Practices Nested Conditionals and Maps in JSX #64

Closed ryansolid closed 6 years ago

ryansolid commented 6 years ago

The use of JSX for setting up the binding tree is incredibly powerful, and is more performant and less memory intensive than context based binding found in fine grained detection libraries. However a couple scenarios I've come across that aren't uncommon that I'm not sure the best way to do in Surplus. Scenarios where the solution is obvious in context based binding but I know the VDOM style plain JS if and map functions wouldn't do the trick with inefficient re-rendering (something VDOM libraries generally don't care about). There are probably solutions but the problems are a bit more involved so understandably they may not be documented or I just misunderstood the documentation.

1. Numbered Dynamic Lists

I'm talking about lists that use the index or second map argument to draw part of the view. I'm not talking about "non-keyed" element reuse but where you don't want to generally not redraw the whole mapped template for each item just update that index. In libraries like Knockout there is a $index observable. With SArray.map memoization prevents redraws, but I'm not quite sure the difference between the different map functions and if there is a way to say only update expressions around the index changing without completely redrawing the whole item the order is changed or items are removed or inserted from the list.

I realize that OL elements solve this in the simple sense but pretend calculating something off this index is important and the expense of redrawing the whole item is undesirable.

2. Lists Nested in Related Conditionals

This is the scenario where if a list is empty you don't want to show the whole section (layout and header) including the list. If you decide to show the section based on if todos().length then every time you add or remove and item the conditional refires causing re-evaluation of the computation and redraws the whole list. I see in TodoMVC example you use CSS to hide the sections which avoids this. The problem here is similar to the intention of SArray.map (or it's variants) where you want to evaluate the incoming value expression independent of evaluating it's mapped template. Is the best course wrapping in a computation to map to S.value so that it only re-evaluates when the resulting expressions value changes?

This was the most obvious case but using naked if statements in general have this issue especially if they contain multiple dependency clauses. So I imagine this pattern might be pretty common place.

Anyway any help would be appreciated. Let me know if my questions are unclear or if I need to give code examples.

adamhaile commented 6 years ago

Good questions.

  1. The general problem here is that SArray isn't well documented. I'm going through a sweep of writing docs right now. It's on the list, but I haven't gotten there yet.

Yeah, unlike knockout's array projections, I don't create an index observable for each item in the array. The rationale is a) most projections don't use it, so why make them pay the cost of constructing and maintaining a data signal for each item, and b) you can write a version that creates it for cases that need it.

Here's an example that acts like SArray.map() but instead of just passing in a static value for the initial index instead passes in an index signal that will change if the item's position in the array changes.

// like SArray.map(), but passes an index signal instead of just the initial index value
const mapIndices = <T, U>(values : () => T[], fn : (v : T, m : U | undefined, i : () => number) => U) : () => U[] => {
    const 
        // create a parallel array of data signals holding the indices
        indices = mapSample<T, DataSignal<number>>(values, (v, m, i) => S.value(i)),
        // create an enumerator that sets the index signals when the array changes
        enumerator = S(() => indices().forEach((index, i) => index(i)));
    // now map the function over the array, passing in each item's index signal
    // we can sample the indices, since they're guaranteed to be parallel
    return map(values, (v, m, i) => fn(v, m, S.sample(indices)[i]));
}
  1. Just hoist the construction of the view out of the conditional. So something like:
cons ToDoViews = (todos) => {
    const view = 
        <div class="todos">
            ... header ...
            {todos.map(ToDoView)}
            ... footer ...
        </div>;
    return S(() => todos().length === 0 ? null : view);
}
ryansolid commented 6 years ago

Thank you for the detailed response. For 1 I suspected as much and truthfully I really never have much use for that as you said. The solution is very much inline with what I was thinking. Involves wrapping a few of the calls and feeding it through.

However, number 2 is so elegant and such a conceptual rethink for me. Sure just javascript so it's obvious. This is completely new territory since there is no equivalent in React or classic DOM or String solutions found for fine grain. Doing the same setup in React doesn't have the same effect since it's life is limited to the lifetime of the render function. (*EDIT actually maybe that's not true)

You are caching nodes and updating them even if they aren't currently attached to the DOM. Like pretend we only show the list if todos().length is > 5. Each add is actually updating these nodes. Obviously if you didn't want that you could avoid it, but the potential to add and remove sections of the page dynamically without reconstructing them is something that like frameworks can't do so trivially. Not to mention In a sense the DOM nodes themselves become part of the closure wrapped state. It's really powerful.

I'm completely sold 100% on JSX instead of context based HTML string binding. This is really great. Thank you. Your approach is a truly evolutionary step forward.