Skip to main content

modify-via-query

Mutate a copy of data without changing the original source with natural and type-safe query.

Why use this library?

This is for users that are sick of updating large states. For example:

setState((state) => ({
  ...state,
  book: {
    ...state.book,
    author: {
      ...state.book.author,
      nickNames: state.book.author.map((name, index) =>
        index === targetIndex ? "new name" : name
      ),
    },
  },
}));

With this library, the code above can be simplified as:

setState(
  modify((state) => state
    .book
    .author
    .nickNames[targetIndex]
    .$set("new name")
  )
);

How to install?

Node.js

npm install modify-via-query --save
import {modify} from "modify-via-query"

Deno

import { modify } from "https://raw.githubusercontent.com/wongjiahau/modify-via-query/master/mod.ts";

Comparison with immutability-helper

Using immutability-helper, taken from this issue:

update(state, {
  objects: {
    [resource]: {
      [id]: {
        relationships: {
          [action.relationship]: {
            data: {
              $apply: data => {
                const { id, type } = response.data;
                const ref = { id, type };
                return data == null ? [ref] : [...data, ref];
              }
            }
          }
        }
      }
    }
  }
});

Using modify-via-query:

modify(state => state
  .objects[resource][id]
  .relationships[action.relationship]
  .data
  .$apply(data => {
    const { id, type } = response.data;
    const ref = { id, type };
    return data == null ? [ref] : [...data, ref];
  })
)(state)

Features

  • Type-safe
  • Autocomplete via Intellisense
  • Chainable
  • Immutable
  • Shallow copy

Main concept

Like the name of this package, you modify by querying the property. The modify function make the object modifiable. A modifiable object comes with a few commands like $set and $apply.
Basically, the commands can be access in any hierarchy of the object, and once the command is invoked, an updated modifiable object will be returned, such that more modifications can be chained.

Examples

Modify object

modify(state => state.x.$set(3))({ x: 2 }) // {x: 3}

Modify array item

modify(state => state[0].$set(3))([1, 2]) // [3, 2]

Modify nested object array

modify(state => state.todos[0].done.$apply(done => !done))({
  todos: [
    {content: "code", done: false},
    {content: "sleep", done: false},
  ]
})
// {todos: [{content: "code", done: true}, {content: "sleep", done: false}]}

Chaining commands

modify(state => state
  .name.$apply(name => name + " " + "squarepants")
  .job.at.$set("Krabby Patty")
)({
  name: "spongebob",
  job: {
    title: "chef"
    at: undefined
  }
})
// { name: "spongebob squarepants", job: {title: "chef", at: "Krabby Patty"} }

Removing array item

modify(state => state.filter((_, index) => index !== 2))(
  ["a", "b", "c"]
)
// ["a", "b"]

Modify property of optional object

For example, if you have the following state:

const state: {
  pet?: {
    name: string
    age?: number
  }
} = {}

Let say you want to update pet.age, you cannot do this:

modify(state => state.pet.age.$set(9))(state)

You will get compile-error by doing so. The is prohibited in order to maintain the type consistency, else the resulting value would be {pet: {age: 9}}, which breaks the type of state, because name should be present.

To fix this, you have to provide a default value for pet using the $default command:

modify(state => state.pet.$default({name: "bibi"}).age.$set(9))(state)

This tells the library that if pet is undefined, then its name will be "bibi" otherwise the original name will be used.

Available commands

  • $set
    • to set the value of the queried property
  • $apply
    • to update the value of the queried property based on its previous value
  • $default
    • to provide a default value if the queried property is a nullable object

Usage with React

Function components (useState hook)

const Counter = () => {
  const [state, setState] = React.useState({count: 0})
  const add = () => setState(modify(state => state.count.$apply(x => x + 1)))
  const minus = () => setState(modify(state => state.count.$apply(x => x - 1)))
  return (...)
}

Class components

class Counter extends React.Component<{}, {count: 0}> {
  constructor(props) => {
    super(props)
    this.state = {count: 0}
  }
  add = () => {
    this.setState(modify(state => state.count.$apply(x => x + 1)))
  }
  minus = () => {
    this.setState(modify(state => state.count.$apply(x => x - 1)))
  }
}

Redux reducers

type State = {count: 0}
type Action = {type: 'add'} | {type: 'minus'}
const myReducer = (state: State, action: Action): State => {
  return modify(state)(state => {
    switch(action.type) {
      case 'add':
        return state.count.$apply(x => x + 1)
      
      case 'minus':
        return state.count.$apply(x => x - 1)
    }
  })
}

Can I use this library in non-React projects?

Yes. Although this library is primarily for users who uses React users, this package can actually be used anywhere since it has zero dependency.

Can I use this library with Typescript?

Yes! In fact the default package already contain the type definitions, so you don’t have to install it somewhere else.

How this library works?

It works by using Proxy

Overloads

The modify function is overloaded with two signatures. If you are using React, the first variant will be more convenient. Note that both of the variants can be curried.

// Update -> State -> State
modify: (update: (state: Modifiable<State>) => Modifiable<State>) 
  => (state: State) 
  => State;

// State -> Update -> State
modify: (state: State) 
  => (update: (state: Modifiable<State>) => Modifiable<State>) 
  => State;

References

This library is inspired by: