adamhaile / S

S.js - Simple, Clean, Fast Reactive Programming in Javascript

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Todo sample, keeping count

naasking opened this issue · comments

S.js looks very promising. I've been playing around with the todo sample, and while it's easy to add a reactive computation that counts the number of completed tasks by calling reduce on the todos SArray, trying to incrementalize by avoiding iterating over all todo entries it yields surprising behaviour.

For instance, defining the completed count as todos.reduce((acc,e) => e.done() ? 1 : acc) works, but it's not incremental because in general there's no inverse for any function you may pass in. So instead I can make the completed count a variable that's updated upon completing a task:

completed = S.data(0),
...
toggleTask = (task) => {
    console.log(task.done());
    var delta = task.done() ? 1 : -1;
    completed(delta + completed());
},
...
        {todos.map(todo =>
            <div>
                <input type="checkbox" fn={data(todo.done)} onChange={() => toggleTask(todo)} />
                <input type="text" fn={data(todo.title)}/>
                <a onClick={() => todos.remove(todo)}>&times;</a>
            </div>)}

But todo.done() returns the opposite value, presumably because it's not yet updated from the checkbox value. You can see it in action here: https://codepen.io/anon/pen/bLaVwP

Is there an obvious or canonical way to solve this that I'm missing?

So just flip the signs of 1 and -1 and it's all good, right? 😆

The data() mixin attaches to the change event too, so in your code above, it comes down to which gets fired first.

The view layer isn't really where you'd want to do such a calculation anyway. For instance, if a completed todo is removed, you'll want to decrement the count. And if we load todos from localStorage, we need to redo the whole count, etc. I think I'd rather do it in the model layer.

Incidentally, I played around with schemes to make the SArray() methods have more optimal change behavior. In the end, I just paralleled the standard array methods for two reasons:

  1. simplicity of use. If you know how to use the standard Array methods, you pretty much know how to use the SArray ones.

  2. applicability. The function doesn't just have to be reversible, it also has to be commutative and expensive enough to matter. In this case, I'm guessing SArray.reduce() would take you well beyond 10k todos before taking longer than a 60hz frame refresh, maybe even 100k.

... but it's still an interesting problem.

Off the top of my head:

  1. I'd make the completed signal a sum signal, which is a variant of a data signal which, instead of a new value, takes a function to transform the old value to the new. So completed(x => x + 1) instead of completed(S.sample(completed) + 1). This isn't part of core S, but can be implemented in a few lines of code. Let me know if you're curious how to do so.

  2. I'd use mapS() to create a computation for each todo which incremented completed() when appropriate and used S.cleanup() to schedule the corresponding decrement. Essentially, the computation creates a side-effect by incrementing completed, which it needs to clean up when it re-runs.

So something like (untested):

const count = S.sum(0),
    inc = () => count(c => c + 1),
    dec = () => count(c => c - 1);
...
todos.mapS(todo => todo.done() && (inc(), S.cleanup(dec)));

I'm absolutely interested in exploring various options. I'm still learning about this whole field of client-side reactivity, vdom, JSX, etc., so please bear with me!

An explicitly commutative variant of reduce which accepts a TAcc merge(TAcc acc, TItem original, TItem current) might solve this problem. So a completed count could be defined as:

todos.creduce(
  (acc, x) => acc + (x.done() ? 1 : 0),
  0,
  (acc, o, x) => acc - (o.done() ? 1 : 0) + (x.done() ? 1 : 0))`.

Or you could overload the regular reduce based on the extra argument for merge. I got this idea from the Concurrent Revisions work at Microsoft which uses similar merge functions to ensure incremental and deterministic concurrent execution.

The only problem is that you'd need to store the original value of any changed signal while a reaction/recomputation is underway, just in case they're needed for a creduce merge. Upside being, you can now support commutative and incremental reduction on an opt-in basis.

I had also considered special sum() and count() methods, but I think creduce() is a more general solution, ie. sum(f) = creduce((acc,x) => acc+f(x), 0, (acc,o,x) => acc-f(o)+f(x)), count(f) = creduce((acc,x) => acc+(f(x) ? 1 : 0), 0, (acc,o,x) => acc - f(o) + f(x)).

I started exploring this question based on a thread where I was discussing various options like MobX and such, and the incremental reduce/completed count issue with S.js came up. So I'm not sure I'd do it this way either, but it ought to be possible and simple to do so.

P.S. Your link to index.d.ts on the S-Array repository should be a link to index.ts.
P.P.S. Alternately, you could define a global map that maps typeof x to merge functions for that type, but I'm not sure this would work given JS's dynamic types and heterogeneous collections.
P.P.P.S. As another possibility, you can define creduce to always return some known commutative type, creduce(f : TItem->Number, seed, merge:Number->Number->Number). So all reduction happens via merge after mapping to a known commutative type, like Number. So counting completed tasks would then simply be todos.creduce(x => x.done() ? 1 : 0, 0, (acc, o, x) => acc - o + x).

Yeah, I was puzzling over how a more general utility could be constructed. My thought was a little like your last form but with three functions: one to extract a value from the item, one to add it to the accumulator, one to subtract.

creduce<T, I, A>(f : T => I, add : (A, I) => A, sub: (A, I) => A, seed : A)

todos.creduce(x => x.done() ? 1 : 0, (acc, x) => acc + x, (acc, x) => acc - x, 0)

I think it'd need three functions, not just two, because it'd have to deal with the case where a value left the collection, not just updated.