I am currently learning functional programming using HyperappJS (V2) and RamdaJS. My first project is a simple blog app where users can comment on posts or other comments. The c
Here's an approach where we 1) locate the target object in your state tree, and then 2) transform the located object. Let's assume that your tree has some way to id
the individual objects -
const state =
{ posts:
[ { id: 1 // <-- id
, topic: "Topic A"
, comments: []
}
, { id: 2 // <-- id
, topic: "Topic B"
, comments: []
}
, { id: 3 // <-- id
, topic: "Topic C"
, comments: []
}
]
, otherstuff: [ 1, 2, 3 ]
}
search
You could start by writing a generic search
which yields the possible path(s) to a queried object -
const search = function* (o = {}, f = identity, path = [])
{ if (!isObject(o))
return
if (f (o))
yield path
for (const [ k, v ] of Object.entries(o))
yield* search (v, f, [ ...path, k ])
}
Let's locate all objects where id
is greater than 1
-
for (const path of search (state, ({ id = 0 }) => id > 1))
console .log (path)
// [ "posts", "1" ]
// [ "posts", "2" ]
These "paths" point to objects in your state
tree where the predicate, ({ id = 0 }) => id > 1)
, is true. Ie,
// [ "posts", "1" ]
state.posts[1] // { id: 2, topic: "Topic B", comments: [] }
// [ "posts", "2" ]
state.posts[2] // { id: 3, topic: "Topic C", comments: [] }
We will use search
to write higher-order functions like searchById
, which encodes our intentions more clearly -
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
for (const path of searchById(state, 2))
console .log (path)
// [ "posts", "1" ]
transform
Next we can write transformAt
which takes an input state object, o
, a path
, and a transformation function, t
-
const None =
Symbol ()
const transformAt =
( o = {}
, [ q = None, ...path ] = []
, t = identity
) =>
q === None // 1
? t (o)
: isObject (o) // 2
? Object.assign
( isArray (o) ? [] : {}
, o
, { [q]: transformAt (o[q], path, t) }
)
: raise (Error ("transformAt: invalid path")) // 3
These bullet points correspond to the numbered comments above -
q
, is None
, the path has been exhausted and it's time to run the transformation, t
, on the input object, o
.q
is not empty. If the input, o
, is an object, using Object.assign
create a new object where its new q
property is a transform of its old q
property, o[q]
.q
is not empty and o
is not an object. We cannot expect to lookup q
on a non-object, therefore raise
an error to signal to that transformAt
was given an invalid path. Now we can easily write appendComment
which takes an input, state
, a comment's id, parentId
, and a new comment, c
-
const append = x => a =>
[ ...a, x ]
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return transformAt // <-- only transform first; return
( state
, [ ...path, "comments" ]
, append (c)
)
return state // <-- if no search result, return unmodified state
}
Recall search
generates all possible paths to where the predicate query returns true. You have to make a choice how you will handle the scenario where a query returns more than one result. Consider data like -
const otherState =
{ posts: [ { type: "post", id: 1, ... }, ... ]
, images: [ { type: "image", id: 1, ... }, ... ]
}
Using searchById(otherState, 1)
would get two objects where id = 1
. In appendComment
we choose only to modify the first match. It's possible to modify all the search
results, if we wanted -
// but don't actually do this
const appendComment = (state = {}, parentId = 0, c = {}) =>
Array
.from (searchById (state, parentId)) // <-- all results
.reduce
( (r, path) =>
transformAt // <-- transform each
( r
, [ ...path, "comments" ]
, append (c)
)
, state // <-- init state
)
But in this scenario, we probably don't want duplicate comments in our app. Any querying function like search
may return zero, one, or more results and you have to decide how your program responds in each scenario.
put it together
Here are the remaining dependencies -
const isArray =
Array.isArray
const isObject = x =>
Object (x) === x
const raise = e =>
{ throw e }
const identity = x =>
x
Let's append our first new comment to id = 2
, "Topic B" -
const state1 =
appendComment
( state
, 2
, { id: 4, text: "nice article!", comments: [] }
)
Our first state revision, state1
, will be -
{ posts:
[ { id: 1
, topic: "Topic A"
, comments: []
}
, { id: 2
, topic: "Topic B"
, comments:
[ { id: 4 //
, text: "nice article!" // <-- newly-added
, comments: [] // comment
} //
]
}
, { id: 3
, topic: "Topic C"
, comments: []
}
]
, otherstuff: [ 1, 2, 3 ]
}
And we'll append another comment, nested on that one -
const state2 =
appendComment
( state
, 4 // <-- id of our last comment
, { id: 5, text: "i agree!", comments: [] }
)
This second revision, state2
, will be -
{ posts:
[ { id: 1, ...}
, { id: 2
, topic: "Topic B"
, comments:
[ { id: 4
, text: "nice article!"
, comments:
[ { id: 5 // nested
, text: "i agree!" // <-- comment
, comments: [] // added
} //
]
}
]
}
, { id: 3, ... }
]
, ...
}
code demonstration
In this demo we will,
state1
by modifying state
to add the first commentstate2
by modifying state1
to add the second (nested) commentstate2
to show the expected statestate
to show that the original state is not modifiedExpand the snippet below to verify the results in your own browser -
const None =
Symbol ()
const isArray =
Array.isArray
const isObject = x =>
Object (x) === x
const raise = e =>
{ throw e }
const identity = x =>
x
const append = x => a =>
[ ...a, x ]
const search = function* (o = {}, f = identity, path = [])
{ if (!isObject(o))
return
if (f (o))
yield path
for (const [ k, v ] of Object.entries(o))
yield* search (v, f, [ ...path, k ])
}
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
const transformAt =
( o = {}
, [ q = None, ...path ] = []
, t = identity
) =>
q === None
? t (o)
: isObject (o)
? Object.assign
( isArray (o) ? [] : {}
, o
, { [q]: transformAt (o[q], path, t) }
)
: raise (Error ("transformAt: invalid path"))
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return transformAt
( state
, [ ...path, "comments" ]
, append (c)
)
return state
}
const state =
{ posts:
[ { id: 1
, topic: "Topic A"
, comments: []
}
, { id: 2
, topic: "Topic B"
, comments: []
}
, { id: 3
, topic: "Topic C"
, comments: []
}
]
, otherstuff: [ 1, 2, 3 ]
}
const state1 =
appendComment
( state
, 2
, { id: 4, text: "nice article!", comments: [] }
)
const state2 =
appendComment
( state1
, 4
, { id: 5, text: "i agree!", comments: [] }
)
console.log("state2", JSON.stringify(state2, null, 2))
console.log("original", JSON.stringify(state, null, 2))
alternate alternative
The techniques described above are parallel to the other (excellent) answer using lenses provided by Scott. The notable difference here is we start with an unknown path to the target object, find the path, then transform the state at the discovered path.
The techniques in these two answers could even be combined. search
yields paths that could be used to create R.lensPath
and then we could update the state using R.over
.
And a higher-level technique is lurking right around the corner. This one comes from the understanding that writing functions like transformAt
is reasonably complex and it's difficult to get them right. At the heart of the problem, our state object is a plain JS object, { ... }
, which offers no such feature as immutable updates. Nested within those object we use arrays, [ ... ]
, that have the same issue.
Data structures like Object
and Array
were designed with countless considerations that may not match your own. It is for this reason why you have the ability to design your own data structures that behave the way you want. This is an often overlooked area of programming, but before we jump in and try to write our own, let's see how the Wise Ones before us did it.
One example, ImmutableJS, solves this exact problem. The library gives you a collection of data structures as well as functions that operate on those data structures, all of which guarantee immutable behaviour. Using the library is convenient -
const append = x => a => // <-- unused
[ ...a, x ]
const { fromJS } =
require ("immutable")
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return transformAt
( fromJS (state) // <-- 1. from JS to immutable
, [ ...path, "comments" ]
, list => list .push (c) // <-- 2. immutable push
)
.toJS () // <-- 3. from immutable to JS
return state
}
Now we write transformAt
with the expectation that it will be given an immutable structure -
const isArray = // <-- unused
Array.isArray
const isObject = (x) => // <-- unused
Object (x) === x
const { Map, isCollection, get, set } =
require ("immutable")
const transformAt =
( o = Map () // <-- empty immutable object
, [ q = None, ...path ] = []
, t = identity
) =>
q === None
? t (o)
: isCollection (o) // <-- immutable object?
? set // <-- immutable set
( o
, q
, transformAt
( get (o, q) // <-- immutable get
, path
, t
)
)
: raise (Error ("transformAt: invalid path"))
Hopefully we can begin to see transformAt
as a generic function. It is not coincidence that ImmutableJS includes functions to do exactly this, getIn
and setIn
-
const None = // <-- unused
Symbol ()
const raise = e => // <-- unused
{ throw e }
const { Map, setIn, getIn } =
require ("immutable")
const transformAt =
( o = Map () // <-- empty Map
, path = []
, t = identity
) =>
setIn // <-- set by path
( o
, path
, t (getIn (o, path)) // <-- get by path
)
To my surprise, even transformAt
is implemented exactly as updateIn
-
const identity = x => // <-- unused
x
const transformAt = //
( o = Map () // <-- unused
, path = [] //
, t = identity //
) => ... //
const { fromJS, updateIn } =
require ("immutable")
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return updateIn // <-- immutable update by path
( fromJS (state)
, [ ...path, "comments" ]
, list => list .push (c)
)
.toJS ()
return state
}
This the lesson of higher-level data structures. By using structures designed for immutable operations, we reduce the overall complexity of our entire program. As a result, the program can now be written in less than 30 lines of straightforward code -
//
// complete implementation using ImmutableJS
//
const { fromJS, updateIn } =
require ("immutable")
const search = function* (o = {}, f = identity, path = [])
{ if (Object (o) !== o)
return
if (f (o))
yield path
for (const [ k, v ] of Object.entries(o))
yield* search (v, f, [ ...path, k ])
}
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return updateIn
( fromJS (state)
, [ ...path, "comments" ]
, list => list .push (c)
)
.toJS ()
return state
}
ImmutableJS is just one possible implementation of these structures. Many others exist, each with their unique APIs and trade-offs. You can pick from a pre-made library or you can custom tailor your own data structures to meet your exact needs. Either way, hopefully you can see the benefits provided by well-designed data structures and perhaps gain insight on why popular structures of today were invented in the first place.
Expand the snippet below to run the ImmutableJS version of the program in your browser -
const { fromJS, updateIn } =
Immutable
const search = function* (o = {}, f = identity, path = [])
{ if (Object (o) !== o)
return
if (f (o))
yield path
for (const [ k, v ] of Object.entries(o))
yield* search (v, f, [ ...path, k ])
}
const searchById = (o = {}, q = 0) =>
search (o, ({ id = 0 }) => id === q)
const appendComment = (state = {}, parentId = 0, c = {}) =>
{ for (const path of searchById(state, parentId))
return updateIn
( fromJS (state)
, [ ...path, 'comments' ]
, list => list .push (c)
)
.toJS ()
return state
}
const state =
{ posts:
[ { id: 1
, topic: 'Topic A'
, comments: []
}
, { id: 2
, topic: 'Topic B'
, comments: []
}
, { id: 3
, topic: 'Topic C'
, comments: []
}
]
, otherstuff: [ 1, 2, 3 ]
}
const state1 =
appendComment
( state
, 2
, { id: 4, text: "nice article!", comments: [] }
)
const state2 =
appendComment
( state1
, 4
, { id: 5, text: "i agree!", comments: [] }
)
console.log("state2", JSON.stringify(state2, null, 2))
console.log("original", JSON.stringify(state, null, 2))
<script src="https://unpkg.com/immutable@4.0.0-rc.12/dist/immutable.js"></script>
Ramda is intentionally designed not to modify user data. Passing something by reference won't help; Ramda will still refuse to alter it.
One alternative is to see if you can pass the path to the node to which you want to add the comment. Ramda can use a path
with lensPath and over to make a version that will return a new state
object, something like this:
const addComment = (state, {text, path}) =>
over (
lensPath (['posts', ...intersperse ('comments', path), 'comments']),
append ({text, comments: []}),
state
)
const state = {
posts: [
{topic: `Topic A`, comments: []},
{topic: `Topic B`, comments: [{text: `Comment`, comments: [
{text: 'foo', comments: []}
// path [1, 0] will add here
]}]},
{topic: `Topic C`, comments: []}
],
otherstuff: {}
}
console .log (
addComment (state, {path: [1, 0], text: 'bar'})
)
//=> {
// posts: [
// {topic: `Topic A`, comments: []},
// {topic: `Topic B`, comments: [{text: `Comment`, comments: [
// {text: 'foo', comments: []},
// {text: 'bar', comments: []}
// ]}]},
// {topic: `Topic C`, comments: []}
// ],
// otherstuff: {}
// }
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script>
<script>const {over, lensPath, intersperse, append} = R </script>
Here the path we use is [1, 0]
, representing the second post (index 1) and the first comment (index 0) within it.
We could write more a more sophisticated lens to traverse the object if the path is not enough.
I don't know if this is an overall improvement, but it's definitely a more appropriate use of Ramda. (Disclaimer: I'm one of the authors of Ramda.)