Zippers in Haskell and Javascript (Part 2)

We’ve seen a zipper being implemented in Haskell, now let’s talk about a concrete example for using Zippers in Javascript.

In the frontend development if we follow elm architecture, we separate our program into:

  • Model - The application state
  • View - Turning your state into HTML
  • Update - Update your state via messages

Of interest to us are the view and update components mentioned.

Suppose you have a table rendered in your html:

Favourite languagesInteresting projects
HaskellPandoc, Servant, Parsec
RustDiesel, gameboy, redox-os
JavascriptMonet.js, Immutable.js

The intuitive underlying architecture should be like so:

[
  { language: "Haskell"
  , projects: ["Pandoc", "Servant", "Parsec"]
  }
, { language: "Rust"
  , projects: ["Diesel", "gameboy", "redox-os"]
  }
, { language: "Javascript"
  , projects: ["Monet.js", "Immutable.js"]
  }
]

Suppose now we want to add another project to Haskell, QuickCheck:

Favourite languagesInteresting projects
HaskellPandoc, Servant, Parsec, QuickCheck
RustDiesel, gameboy, redox-os
JavascriptMonet.js, Immutable.js

Our state should look like this:

[
  { language: "Haskell"
  , projects: ["Pandoc", "Servant", "Parsec", "QuickCheck"]
  }                                           // We appended QuickCheck here
, { language: "Rust"
  , projects: ["Diesel", "gameboy", "redox-os"]
  }
, { language: "Javascript"
  , projects: ["Monet.js", "Immutable.js"]
  }
]

One way would be to access the projects array for Haskell and mutate it:

state.forEach(row => {
  row.language === "Haskell" // Access the row for Haskell
    ? row.projects // Access the projects array
         .push("QuickCheck") // mutate the projects array
    : {}
})

This is not ideal however. It is difficult to find the source of side effects since they are not strictly local.

How can we do this in a pure way instead?

We want use reducers which do updates to state in a pure way.

If we just have this:

const addRowProject = (projects, newProject) => {
  return projects.concat(newProject)
}

We can provide a path and traverse down the state:

const addProject = (project, newProject) => 
  state.map(row => 
    row.language === project
      ? { ..row, projects: row.projects.concat(newProject) }
      : row)

However the traversal step takes extra time for us, making it inefficient.

Instead, we can provide the context as we traverse down the state, mapping the view and update components.

In this case our context will be the adjacent rows (left and right).

We also provide the location, which is the row itself.

const renderTable = (tableState) => {
  return (
    // ...
    <TableHeaders />
    { foldInRightDirection(tableState, renderRow) }
    // ...
  )
}

const NOTHING = "NOTHING"

const foldInRightDirection = ({leftRows, rightRows}, reducer) => {
  let right = moveRight(rightRows)
  return right === NOTHING
    ? [] // Terminate
    : reducer(leftRows, right.rowLocation, right.rightRows)
        .concat(foldInRightDirection({
          leftRows: leftRows.concat(right.rowLocation),
          rightRows: rightRows
        }))
}

const moveRight = (rows) => {
  return rows.length === 0
    ? NOTHING
    : { rowLocation: rows[0], rightRows: rows.slice(1) }
}

const renderRow = (tableState) => {
  return (
    useReducer(addProject, tableState)
    // ...
    // On a button press
    onClick = () => {
        dispatch({
            type: "ADD_PROJECT",
            payload: project
        })
    }
    // ...
  )
}

With all these information we can update without having to traverse each time:

const addProject = ({type, payload}, {rowLocation, leftRows, rightRows}) => {
  switch (type) {
    case "ADD_PROJECT":
      const newRow = {..rowLocation, projects: rowLocation.projects.concat(payload.project)}
      return { leftRows: []
             , rightRows: leftRows.concat(newRow)
                                  .concat(rightRows)
             }
  }
}

Part 3 - To be continued… Swapping rows.

References: Elm guide