Qim: Select from Your Immutable JavaScript Cake and Update It Symmetrically Too
If you're reading this article, you're probably wondering a few things:
- What the heck is Qim?
- What the heck do I mean by "symmetrically"?
- Is "JavaScript cake" a useful analogy or just an attempt to be clever?
- How do I pronounce Qim?
So, let me try to answer those right now:
- Qim is a JavaScript library for handling immutable data with simple but powerful query paths. If you're thinking "oh god, not another immutable data library," hang on, I promise this one is at least different. If you're thinking "oh god, 'query' implies some weird DSL," I promise it's all just plain, composable JavaScript.
- "Symmetrically" refers to the way that Qim can update a JavaScript object with the same query path used to select from a JavaScript object. This will make sense soon enough with some examples.
- Sorry, nothing to do with cake here, but that was a little more than an attempt to be clever. Besides accentuating the second point, I'm referring to being able to query from deeply nested, plain JavaScript objects and arrays. Qim doesn't require wrapping your JS objects with any special wrapper. It doesn't require proxies or any other performance tax. You don't have to muck with prototypes to extend its usefulness. It doesn't stop you from using other libraries that expect plain JS data. It's just a simple functional library that allows you to do complex selections and updates on complex (or not-so-complex) data.
- Qim is pronounced Kim. Because the internet says so. And me. The internet and me. It’s decided.
Why Do We Need Another Javascript Library for Immutable Data?
If you're like our frontend and full-stack engineers at Zapier, you might have gone through a series of life-changing realizations that went something like this:
- React looks awesome. We have to try this.
- React is awesome! Everything else is dead to us.
- React is making us think functionally. Functional programming is pragmatic after all!
- Hmm, mutating data is bad. If we use immutable data, things are much simpler. But making immutable changes to our data is kind of a PITA.
- Redux looks awesome. We have to try this.
- Redux is awesome! And it enforces using immutable data. So we have to give up mutating data entirely. Okay, gotta find a better way to do immutable data.
- Immutable.js looks awesome. We have to try this.
- Ugh, using Immutable.js means giving up plain and simple JS data. That means lots of marshalling to interoperate with existing code or other libraries.
- Okay, React has this immutability helper which is pretty simple and works with plain JS. So let's use that.
- We'd like to add some new commands to the immutability helper, so let's fork it.
- Hmm, this immutability helper is getting kind of weird. And our code is a mix of one-off functions, this other library used in a few places, and our immutability helper fork.
- Some of our code for updating data is crazy complex. Relatively simple changes to nested data can become a computer science exercise.
You may have followed a different path, but if you hit bumps on the road to using immutable data and still find things harder than they should be, Qim might be able to help.
Like Lodash
Okay, enough of the sales pitch, let's see some code. If you've used get
or set
from Lodash, a simple Qim example should be familiar.
import {find, set} from 'qim'; const todoState0 = { todos: { todo1: { text: 'invent time machine', isDone: true }, todo2: { text: 'fix msitakes', isDone: false } }, ids: ['todo1', 'todo2'] }; const todoText = find( ['todos', 'todo1', 'text'], todoState0 ); const todoState1 = set( ['todos', 'todo2', 'text'], 'fix mistakes', todoState0 );
After this, todoText
is "invent time machine"
, and todoState1
has its mistakes fixed:
{ todos: { todo1: { text: 'invent time machine', isDone: true }, todo2: { text: 'fix mistakes', isDone: false } }, ids: ['todo1', 'todo2'] }
Of course, todoState0
is untouched because, well, otherwise, Qim wouldn't be a very good library for "handling immutable data."
At this point, find
seems synonymous with get
from Lodash, and set
is synonymous with set
from Lodash. But Qim is data-last and curried, and Qim never mutates the original data. So really, it's more synonymous with Lodash/fp or Ramda. Soon enough, though, the similarities will end.
A Quick Aside on Currying
If you're unfamiliar with currying, here's a super-duper quick example to explain it:
const getText = find(['todos', 'todo1', 'text']); const todoText = getText(todoState1);
If you leave off any of the parameters to Qim's functions, you get a function back and can later use that function on the data. That isn't a very useful example, but with more complex queries it can come in handy. (You'll see currying pop up in later examples.) A deeper explanation of currying is way outside the scope of this article. Just google for "JavaScript currying," and you'll find plenty of good articles on the topic. (Or you might find some haters. It can sometimes be confusing.)
Not Like Lodash at All
So far, you're probably wondering what's the big deal here (besides my name, don't wear it out). That's the idea though: Simple things should be as unfancy as possible. I promise that things are about to get more interesting though.
Unlike Lodash's paths, Qim's queries can be made up of more than just strings. Qim's queries are actually made up of "navigators." (The "navigator" concept is borrowed from Specter, a Clojure library by Nathan Marz.) Strings are just one simple type of navigator that let you query the value associated to a key from an object. Let's show off some other navigators now.
import {select, update, $each, $apply} from 'qim'; const todosText = select( ['todos', $each, 'text'], todoState1 ); const upperTodoState = update( ['todos', $each, 'text', $apply(text => text.toUpperCase())], todoState1 );
After this, todosText
is:
['invent time machine', 'fix mistakes']
And upperTodoState
is:
{ todos: { todo1: { text: 'INVENT TIME MACHINE', isDone: true }, todo2: { text: 'FIX MISTAKES', isDone: false } }, ids: ['todo1', 'todo2'] }
find
and set
are actually sugar over select
and update
, the core utilities of Qim. (It's a little more nuanced than that, but that's close enough without digging into the code.) select
finds all occurrences that match the given path. And update
updates all values that match the given path. $each
is a navigator that acts like a wildcard. It matches all values of an object or array. $apply
is a parameterized navigator that takes a function and transforms the current value.
Hopefully you're starting to see some of the power that Qim provides. Or, you're thinking "what up with those dollar signs?" Those are just a convention to differentiate declarative navigators from other functions. Navigators, even if they sometimes are functions, don't curry, because they don't ever do any work anyway. They just stand there waiting for navigations to occur.
Another difference from Lodash: Qim tries to avoid creating new objects if nothing actually changes.
const upperTodoState = update( ['todos', $each, 'text', $apply(text => text)], todoState1 );
After this, upperTodoState
points to the same object as todoState1
, because nothing actually changed.
Symmetry
Notice that the select and update queries look similar. The only difference is that the update tacks on an extra $apply
navigator to do a transformation. This is that promised symmetry. Let's do the same select and update using vanilla JavaScript and compare.
const todosText = Object.keys(todoState1.todos) .map(key => todoState1.todos[key].text); const upperTodoState = { ...todoState1, todos: Object.keys(todoState1.todos) .reduce((result, key) => { const todo = todoState1.todos[key]; result[key] = { ...todo, text: todo.text.toUpperCase() }; return result; }, {}) };
The equivalent select is pretty simple and expresses the problem pretty well. But the equivalent update gets a lot more noisy and bears little resemblance to the select. We can illustrate Qim's symmetry even more using another navigator.
import {select, update, $each, $apply, $nav} from 'qim'; const $eachTodoText = $nav(['todos', $each, 'text']); const todosText = select( [$eachTodoText], todoState1 ); const upperTodoState = update( [$eachTodoText, $apply(text => text.toUpperCase())], todoState1 );
We're getting a little ahead of ourselves, but we can use $nav
to create a custom navigator from other navigators. This way, we can compose queries from other queries. We'll talk more about that later. For now, it helps illustrate the symmetry of select and update.
Let's compare to Lodash/fp to see if we can make something less ugly than vanilla JS.
import fp from 'lodash/fp'; const todosText = fp.flow( fp.get('todos'), fp.map(fp.get('text')) )(todoState1); const upperTodoState = fp.update('todos', fp.mapValues( fp.update('text', text => text.toUpperCase()) ), todoState1);
This is a lot better, but it still lacks Qim's symmetry. And, as our queries get more complex, Qim will stay expressive while Lodash/fp will get more complex, and updates will deviate even further from selects.
Predicates
Here's where things get really interesting. Let's select the text of all the completed todos.
const completedTodos = select( ['todos', $each, todo => todo.isDone, 'text'], todoState1 );
completedTodos
is now:
['invent time machine']
A function acts as a predicate navigator. Used with $each
, predicate functions act as filters on the values of objects or arrays. And of course, we get the same symmetry with updates. Let's change all completed todos to upper-case.
const upperCompletedTodos = update([ 'todos', $each, todo => todo.isDone, 'text', $apply(text => text.toUpperCase()) ], todoState1);
This gives us:
{ todos: { todo1: { text: 'INVENT TIME MACHINE', isDone: true }, todo2: { text: 'fix mistakes', isDone: false } }, ids: ['todo1', 'todo2'] }
Hopefully you see the pattern here. If you know how to select data with Qim, you know how to update with Qim. Let's compare again with Lodash/fp.
import {flow, get, filter, map, update, mapValues} from 'lodash/fp'; const completedTodos = fp.flow( fp.get('todos'), fp.filter(fp.get('isDone')), fp.map(fp.get('text')) )(todoState1); const upperCompletedTodos = fp.update('todos', fp.mapValues( todo => { if (todo.isDone) { return fp.update( 'text', text => text.toUpperCase(), todo ); } return todo; } ), todoState1);
Here's where the whole "navigator" concept becomes obvious. With Lodash/fp, our select involved a filter, which was pretty obvious. But our update can't use a filter, because we'll filter out parts of the object that we want to keep. So our update is less declarative than our select. We have to break out of point-free style, and we have to add a condition to our mapValues
function.
With Qim, our predicate navigator just selects parts of an object that will be passed along to the rest of the query. But the parts that aren't selected aren't removed. They stay intact. Immutable updates are just as obvious as immutable selects.
Nested Queries
What if you want to update multiple things in the same query? You can use arrays to perform nested queries.
import {update, $set} from 'qim'; const todoState1 = update(['todos', 'todo2', ['text', $set('fix mistakes')], ['isDone', $set(true)] ], todoState0);
Now todoState1
is:
{ todos: { todo1: { text: 'invent time machine', isDone: true }, todo2: { text: 'fix mistakes', isDone: true } }, ids: ['todo1', 'todo2'] }
Using an array as a navigator causes Qim to branch the query. When the array closes, you're back where you were.
We also introduced $set
here. $set
is like $apply
but transforms the navigated value to a constant instead of applying a function.
You can nest select
queries as well.
const results = select(['todos', 'todo2', ['text'], ['isDone'] ], todoState1);
Since you typically want homogenous data for a select, that's a bit more unusual. Here, our results
would be:
['fix mistakes', true]
Put $each
, predicates, and nested queries all together, and you can do complex updates with readable code. Let's add an isArchived
flag to our completed todos and a due date to our incomplete todos.
const todoState2 = update(['todos', $each, [todo => todo.isDone, 'isArchived', $set(true)], [todo => !todo.isDone, 'dueDate', $set('2020/02/20')] ], todoState1);
Our todoState2
is now:
{ todos: { todo1: { text: 'invent time machine', isDone: true, isArchived: true }, todo2: { text: 'fix mistakes', isDone: false, dueDate: '2020/02/20' } }, ids: ['todo1', 'todo2'] }
Other Useful Navigators
Qim has lots of other navigators. Let's touch on a few more.
Removing a Property or Item ($none)
If you want to remove a property from an object or an item from an array, you can use the $none
navigator. Let's remove all the completed todos.
const cleanedTodos = update([ ['todos', $each, todo => todo.isDone, $none], ['ids', $each, id => todoState1.todos[id].isDone, $none] ], todoState1);
Now cleanedTodos
is:
{ todos: { todo2: { text: 'fix mistakes', isDone: false } }, ids: ['todo2'] }
Iterating Over Pairs ($eachPair)
If you want to use the key as well as the value when iterating over an object, you can use $eachPair
. Let's select out some pairs just so it's obvious what pairs look like.
const pairs = select( ['todos', $eachPair], todoState1 );
Our pairs are just arrays with two items. The first item is the key, and the second item is the value.
[ ['todo1', {text: 'invent time machine', isDone: true}], ['todo2', {text: 'fix mistakes', isDone: false}] ]
That's similar to toPairs
in Lodash, but now let's update some pairs to add an id
property to each todo.
import {update, $eachPair} from 'qim'; const todoState2 = update( ['todos', $eachPair, $apply( ([id, todo]) => [id, {...todo, id}] )], todoState1 );
We get:
{ todos: { todo1: { id: 'todo1', text: 'invent time machine', isDone: true }, todo2: { id: 'todo2', text: 'fix mistakes', isDone: false } }, ids: ['todo1', 'todo2'] }
$eachPair
converts each key and value to a pair (array of key and value), and we modify that pair as if it existed in the original data. We return a new pair, and $eachPair
handles turning our new pairs back into an object. This is different from toPairs
in Lodash where you have to apply the corresponding fromPairs
to get back an object. Keep this in mind when we create custom navigators later.
Prepending/Appending to an Array ($begin/$end)
You can append to an array with $end
(and similarly prepend with $begin
). Let's add a new todo.
import {update, $end} from 'qim'; const todoState2 = update([ ['todos', 'todo3', $set({text: '', isDone: false})], ['ids', $end, $set(['todo3'])] ], todoState1);
Our new state with the added todo:
{ todos: { todo1: { text: 'invent time machine', isDone: true }, todo2: { text: 'fix mistakes', isDone: false }, todo3: { text: '', isDone: false } }, ids: ['todo1', 'todo2', 'todo3'] }
Notice that $end
selects an empty array from the end of the array, so you set it to a non-empty array to append any number of items.
All the Other Navigators
There are lots of other basic navigators. I'll spare you from more boring examples, but here's a quick summary of a few more:
$first
and $last
, unsurprisingly, navigate to the first and last items of an object or array.
$slice
navigates to a slice of an array, and similarly, $pick
navigates to a subset of an object.
You can check out these and all the other basic navigators in the Qim README. For now, buckle up, because we're going to start looking at some of Qim's more advanced navigators.
Custom Navigators
Because queries are just lists of navigators, it's pretty straightforward to extend Qim. Just add new navigators. And it's pretty straightforward to create new navigators: Just build on top of existing parameterized navigators. As a simple example, let's add a $true
and a $false
navigator.
const $true = $set(true); const $false = $set(false);
Now we can use those just like any other navigators.
const todoState2 = update( ['todos', 'todo2', 'isDone', $true], todoState1 );
To make parameterized navigators, just create functions that return navigators. We learned about $nav
a little earlier. Let's use that to create a parameterized navigator.
const $todo = id => $nav(['todos', id]); const $markDone = $nav(['isDone', $true]); const todoState2 = update( [$todo('todo2'), $markDone], todoState1 );
That's a little contrived, but you get the idea. You can compose queries pretty easily by building on top of $nav
.
Crazier Custom Navigators Using $nav
Now, let's reveal some of $nav
's secret sauce and start making really custom navigators. Remember this example where we used $eachPair
to add an id
(from the key) to each of our todos?
const todoState2 = update( ['todos', $eachPair, $apply( ([id, todo]) => [id, {...todo, id}] )], todoState1 );
If we could get id
into scope somehow, we could just use a path like ['id', $set(id)]
to set the id
property instead of using an $apply
and reverting back to vanilla JS. To clarify what we want, let's first set the id
property for each todo to a static value.
const todoStateStaticId = update( ['todos', $eachPair, 1, 'id', $set('foo')], todoState1 );
The 1
navigates to the second item in the pair which is the todo itself. Then we navigate to the id
property and set that to a static value. That gives us:
{ todos: { todo1: { id: 'foo', text: 'invent time machine', isDone: true }, todo2: { id: 'foo', text: 'fix mistakes', isDone: false } }, ids: ['todo1', 'todo2'] }
But we don't want to set id
to 'foo'
. We want to set it to a dynamic value based on first item in the pair, which is the key of the todo. But how do we get a dynamic value into our $set
? We can do that with $nav
.
const todoState2 = update( ['todos', $eachPair, $nav(([id]) => [1, 'id', $set(id)] )], todoState1 );
$nav
takes a function that receives the current navigated object as the first parameter, and it can return a dynamic query path to continue navigating. In this example, we navigate to each pair, and:
- Put the
id
into scope. The function passed to$nav
gets the array pair as the first parameter, so we use array destructuring to pull off the key part of the pair and bind it to a local variableid
. - Dynamically navigate to the value part of the array pair with
1
, just like we did with our static navigation. - Navigate to the
id
property which doesn't exist but will be created. By default, if a key doesn't exist, and you continue navigating,qim
assumes that you want to create an object. (You can use $default if you want to change that behavior.) - Use
$set
to set the value ofid
to the key part of the pair.
After that, we have:
{ todos: { todo1: { id: 'todo1', text: 'invent time machine', isDone: true }, todo2: { id: 'todo2', text: 'fix mistakes', isDone: false } }, ids: ['todo1', 'todo2'] }
Now that we can dynamically choose a path, we can go completely mad scientist. Hold onto your butts…
First, assume we have a tree of todos like this:
const todoTree = [ { id: 'todo1', text: 'invent time machine', isDone: false, todos: [ { id: 'todo2', text: 'find delorean', isDone: true }, { id: 'todo3', text: 'go 88 mph', isDone: false } ] }, { id: 'todo4', text: 'fix mistakes', isDone: false } ];
Now, let's cook up this navigator.
const $walkTodos = $nav(value => { // If the current value is an array, iterate over each item and recurse. if (Array.isArray(value)) { return [$each, $walkTodos]; } if (value.todos) { // If we have child todos, we need to navigate to multiple paths. return $nav( // Just a no-op path that will continue with the rest of the query. [], // Navigate to the child todos and recurse. ['todos', $walkTodos] ); } // Just a no-op path that will continue with the rest of the query. return []; });
Recursive queries! Fun! We also see another trick up $nav
's sleeve. It can take multiple paths, and it will navigate along each one. If you're confused about the difference between this and nested queries (arrays), just remember that nested queries don't really affect the path followed by the rest of the query. So in a query like this:
['a', ['b'], ['c'], 'd']
We'll navigate along the paths ['a', 'b']
, ['a', 'c']
, and ['a', 'd']
. We could remove the subqueries, and we would still navigate to ['a', 'd']
. With a query like this:
['a', $nav(['b'], ['c']), 'd']
We'll navigate to ['a', 'b', 'd']
and ['a', 'c', 'd']
.
With that tangent out of the way, let's use our fancy navigator!
const values = select([$walkTodos, 'text'], todoTree);
This fine piece of recursive work gives us:
[ 'invent time machine', 'find delorean', 'go 88 mph', 'fix mistakes' ]
And of course we can use it for an update too:
const todoTreeUpper = update( [$walkTodos, 'text', $apply(value => value.toUpperCase())], todoTree );
And we end up with:
[ { "id": "todo1", "text": "INVENT TIME MACHINE", "isDone": false, "todos": [ { "id": "todo2", "text": "FIND DELOREAN", "isDone": true }, { "id": "todo3", "text": "GO 88 MPH", "isDone": false } ] }, { "id": "todo4", "text": "FIX MISTAKES", "isDone": false } ]
Just like I promised, the symmetry has followed us all the way to crazy complex queries. Recursive queries like this are not often necessary, but it's nice to know that Qim has the power when you need it. And it's nice to know that you can hide all that complexity behind a single navigator like $walkTodos
.
Less Crazy Custom Navigators Using $lens
Hopefully you survived the recursive descent into madness in the last section. "Lens" might sound like we're going deeper, but it's actually pretty simple. It's really just a two-way version of $apply
. In fact, Qim could have just added another parameter to $apply
, but that fancy "lens" word was just too tempting.
As an example, let's say we want to create a navigator that treats our todo state as an array of todos. We could easily map to an array with $apply
, but we need a way to convert from an array back to our state object.
import {$lens} from 'qim'; const $todosArray = $lens( // Map our state object to an array. (todoState) => { return todoState.ids.map(id => ({ ...todoState.todos[id], id })); }, // Map our array back to a state object. (todosArray, todoState) => { return todosArray.reduce((newTodoState, todoWithId) => { const {id, ...todo} = todoWithId; newTodoState.todos[id] = todo; newTodoState.ids.push(id); return newTodoState; }, { ...todoState, todos: {}, ids: [] }); } )
The first function passed to $lens
will get the current value, just like an $apply
. In our example, we map over the ids
to convert the state into an array. The transformed value will be passed to the rest of the query.
For an update, that transformed value will be passed into the second function, along with the original value. In our example, we reduce that array back to an object. Once we've created the navigator, we can use it like any other.
const upperIdState = update( [$todosArray, $each, 'id', $apply(id => id.toUpperCase())], todoState1 );
We're able to change our id
property in one place, and that change is reflected back to both places in the state.
{ todos: { TODO1: { text: 'invent time machine', isDone: true }, TODO2: { text: 'fix mistakes', isDone: false } }, ids: [ 'TODO1', 'TODO2' ] }
Kind of cool, huh? Again, you probably don't want to do a radical transformation like this every day, but it's nice to know the option is there.
Lower-Level Custom Navigators with $traverse
The last stop on the custom navigator train is $traverse
. If you want to make efficient navigators or navigators that iterate like $each
, then $traverse
is going to give you the most control.
Let's implement a completely contrived $eachTodo
iterator that works like a combination of our $todosArray
above and $each
.
import {isReduced, $traverse} from 'qim'; const $eachTodo = $traverse({ select: (todoState, next) => { for (let i = 0; i < todoState.ids.length; i++) { const id = todoState.ids[i]; const result = next({ ...todoState.todos[id], id }); if (isReduced(result)) { return result; } } return undefined; }, update: (todoState, next) => { const todos = todoState.ids.map(id => ({ ...todoState.todos[id], id })); const newTodos = todos.map(todo => next(todo)); return newTodos.reduce((newTodoState, todoWithId) => { const {id, ...todo} = todoWithId; newTodoState.todos[id] = todo; newTodoState.ids.push(id); return newTodoState; }, { ...todoState, todos: {}, ids: [] }); } });
Now we can do this:
const upperIdState = update( [$eachTodo, 'id', $apply(id => id.toUpperCase())], todoState1 );
Using $traverse
, you have complete control over whether the rest of the query gets called (and how many times it gets called) via the next
function. This is different from $lens
where next
is implicitly called once and only once. Also, unlike $lens
where the select function feeds into the update function, for $traverse
, the two halves can have completely independent implementations.
Of course, if you've been paying attention, you might be thinking we could have just done this:
const $eachTodo = $nav([$todosArray, $each]); const upperIdState = update( [$eachTodo, 'id', $apply(id => id.toUpperCase())], todoState1 );
And you'd be right! But you may have also noticed that imperative for loop and that crazy isReduced
utility. If we use our custom $traverse
implementation of $eachTodo
, then this is efficient:
const firstTodo = find([$eachTodo], todoState1);
It will only create and send the first todo item through next
. find
will wrap that one in a "reduced" wrapper, and we'll break out of our for loop because of the isReduced
check. With the $nav
version, we always create the full array even if we don't use all of it.
I recommend only reaching for this level of control if you really need it. The higher-level $nav
and $lens
are typically going to perform well enough for most jobs. Also, I'd like to start experimenting with using lazy sequences and see if I can make the $nav
version just as efficient as the custom $eachTodo
navigator.
Context
If you're still with me, let's turn things up to 11. Or if you thought they were already at 11, I guess, uh, 12?
Any time you hear about something called "context," it's usually a feature that you're supposed to use sparingly. It's the same with Qim. This feature is usually something to avoid, but it's essential for some cases.
Just to illustrate, let's use context where it's not necessary. Let's tweak the text of all of our todos, tacking on the id
.
import {update, $eachPair, $setContext} from 'qim'; const todosWithIdSuffixState = update([ 'todos', $eachPair, $setContext('id', find(0)), 1, 'text', $apply((text, ctx) => `${text} (${ctx.id})`) ], todoState1);
We use $setContext
to save a value to the context. Don't be thrown by the find(0)
. That's just making use of currying. We could have written it like this:
$setContext('id', pair => pair[0])
$setContext
takes a key that determines where to store the context and a function which takes the current value and returns the value to store at that key. The context only applies to that particular path. So for each pair, we store a different id
value in the context.
Later, we pull the value back out of context in our $apply
function. Up till now, it's been a secret, but the second parameter to our $apply
function is the context.
The result of this is:
{ todos: { todo1: { text: 'invent time machine (todo1)', isDone: true }, todo2: { text: 'fix mistakes (todo2)', isDone: false } }, ids: [ 'todo1', 'todo2' ] }
$nav
also has that same second context parameter. So you can create navigators that pass along data to each other.
const $setFirstTodo = $setContext( 'firstTodoId', find(['ids', $first]) ); const $getFirstTodo = $nav((todos, ctx) => [ctx.firstTodoId]); const firstTodoText = find( [$setFirstTodo, 'todos', $getFirstTodo, 'text'], todoState1 );
After this rather contrived example, firstTodoText
is "invent time machine"
. You should not use context for simple cases like this. Instead, just use a local variable, or use $nav
to pull something into scope. But, again, the power is there if you need it.
Okay, ready for full crazy? Remember that tree of todos? And that $walkTodos
navigator? Let's tweak that a bit:
import {$nav, $pushContext} from 'qim'; const $pushPath = $pushContext('path', todo => todo.id); const $walkTodos = $nav(value => { if (Array.isArray(value)) { return [$each, $walkTodos]; } if (value.todos) { return $nav( [$pushPath], [$pushPath, 'todos', $walkTodos] ); } return [$pushPath]; });
Here we're using $pushContext
(which is really just a variant of $setContext
) that appends the value to an array. That way, you can grab all the collected values in your $apply
function. And this is good for, you guessed it: Recursion!
Let's select out a flattened list of all of the todo text along with a path string.
const flatList = select( [$walkTodos, 'text', $apply((text, ctx) => ({path: ctx.path.join('/'), text}))], todoTree );
And we get:
[ { path: 'todo1', text: 'invent time machine' }, { path: 'todo1/todo2', text: 'find delorean' }, { path: 'todo1/todo3', text: 'go 88 mph' }, { path: 'todo4', text: 'fix mistakes' } ]
Okay, if you've had enough crazy, that's it. There's no more code. Just words.
Performance
So how fast is Qim? Well, the primary goal of Qim is to be expressive, but actually, it's pretty fast. If you're currently using Lodash/fp, Qim is going to be quite a bit faster. If you're using Ramda, Qim will probably be faster in some areas, slower in others, but overall about equivalent. For Immutable.js, it all depends on how much you're pushing Immutable.js and how much marshalling you're doing. For huge lists, Immutable.js is great. If you do lots of marshalling back and forth to vanilla JS objects though, well, that's a lot of why Qim exists. It lets you use plain JS objects and keeps things performant enough.
There are some benchmarks in the Qim repo. As with all micro-benchmarks, take all this with a grain of salt though and try it for yourself!
Stability
Should you use Qim?
Qim is new and might change! There are lots of crazy ideas here, and it's likely I've made a few mistakes. Having said that, we're using it in production at Zapier now, so I won't be making any radical changes without thinking them through carefully! For now, I'd probably recommend pinning to a specific version to be safe, though.
Thanks
If you made it this far, thanks for reading about Qim! You survived my simple, contrived, and sometimes crazy examples. The idea was to show you how Qim can scale from tiny problems all the way to complex ones. Hopefully you've seen enough to give it a try!
If so, you can keep hacking on the examples with the REPL links throughout this article, or you can work on a blank slate that has all of Qim's functions ready to use. If you want to dig in a little more, install Qim or clone the repo. All the examples in this article are in the Qim repo as unit tests.
Have fun!
Comments powered by Disqus