Using Local Storage To Persist Data
August 18th, 2019
🥡🥡 10 min read

The browser comes equipped with a mechanism called the Web Storage API that can be used to store data in key/value pairs. Session Storage persists data for the current session, which ends when the browser tab closes. Local Storage, on the other hand, persists data even if the browser is closed and reopened. Both of these storage interfaces can be manipulated with JavaScript and can be used in web applications. To illustrate Local Storage in action this article will walk through the process of adding a saved colors feature to the color picker from How To Build A Color Picker. The project is built using Code Sandbox so don't worry if you haven't read the previous article. I created a new fork of the original sandbox as a jump-off point for this article.

Here is the starting point for this article:


UI Setup

Most of the new code is going into the Picker component. The first step is to add some new UI elements to display the saved colors. New lines of code will be highlighted.

The config.js shares global constants. Add colorsSize which is the height of the saved color section.

config.js
export default {
  squareSize: 200,
  barSize: 20,
  crossSize: 15,
  inputSize: 40,
  colorsSize: 40,  delay: 150
}

The new UI will appear as five boxes and a plus sign. The new colors state variable is initialized as an array of five empty string. The Array.from syntax is a little bit of overkill for a five item array but is good to know when creating a larger array is needed. A length property and callback function are supplied. For future reference know that the index is passed as the seconds argument to the callback.

With that tangent over, just know that the idea is to add the current color when the plus is clicked. Clicking the color will move the Handle and Cross components to match the values of the saved color. As a bonus, the color will be copied to the clipboard. There should also be a method to delete a color.

Picker.js
import React, { useState, useRef, useEffect } from 'react'
import styled from 'styled-components'
import Modal from './Modal'
import Hue from './Hue'
import Square from './Square'
import Input from './Input'
import config from './config'

const { squareSize, barSize, crossSize, inputSize, colorsSize } = config
export const PickerWrapper = styled.div`
  user-select: none;
  .swatch {
    width: 100px;
    height: 50px;
    background: ${p => p.color};
  }
`

export const PickerOuter = styled.div`
  width: ${squareSize + 20}px;
  display: grid;
  border-radius: 2px;
  background: #ffffff;
  box-shadow: 0 3px 3px rgba(0, 0, 0, 0.3);
`

export const PickerInner = styled.div`
  display: grid;
  grid-template-rows: ${squareSize + 20}px ${barSize}px ${inputSize}px ${colorsSize}px;
  align-items: center;
  justify-items: center;
`

export const Inputs = styled.div`
  width: 100%;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: center;
  justify-items: center;
`
export const Colors = styled.div`  width: 100%;  height: 25px;  display: grid;  grid-template-columns: repeat(5, 35px) 1fr;  align-items: center;  justify-items: flex-end;`export const Color = styled.div`  width: 25px;  height: 25px;  background: ${p => p.color || '#fafafa'};  border: 1px solid #ddd;`export const AddColor = styled.div`  justify-self: center;  width: 25px;  height: 25px;  display: grid;  align-items: center;  justify-items: center;  font-size: 20px;`
function computeHueX(h) {
  return Math.round((squareSize / 360) * h - barSize / 2)
}

function computeSquareXY(s, l) {
  const t = (s * (l < 50 ? l : 100 - l)) / 100
  const s1 = Math.round((200 * t) / (l + t)) | 0
  const b1 = Math.round(t + l)
  const x = (squareSize / 100) * s1 - crossSize / 2
  const y = squareSize - (squareSize / 100) * b1 - crossSize / 2
  return [x, y]
}

const Picker = () => {
  const [show, setShow] = useState(true)
  const [hue, setHue] = useState(180)
  const [hueX, setHueX] = useState(() => squareSize / 2 - barSize / 2)
  const [square, setSquare] = useState([100, 50])
  const [squareXY, setSquareXY] = useState(() => [squareSize - crossSize / 2, crossSize / -2])
  const [offsetTop, setOffsetTop] = useState(0)
  const [offsetLeft, setOffsetLeft] = useState(0)
  const [color, setColor] = useState(`hsla(180, 100%, 50%, 1)`)
  const [animate, setAnimate] = useState(false)
  const [colors, setColors] = useState(() => Array.from({ length: 5 }, () => ''))
  const modal = useRef(null)

  // TODO
  // add localStorage logic

  useEffect(() => {
    function setOffsets() {
      setOffsetTop(modal.current.offsetTop)
      setOffsetLeft(modal.current.offsetLeft)
    }
    if (show) {
      setOffsets()
      window.addEventListener('resize', setOffsets)
    } else {
      window.removeEventListener('resize', setOffsets)
    }

    return () => {
      window.removeEventListener('resize', setOffsets)
    }
  }, [show])

  useEffect(() => {
    setColor(`hsla(${hue}, ${square[0]}%, ${square[1]}%, 1)`)
  }, [hue, square])

  function onHueChange(n) {
    setAnimate(true)
    setHue(n)
    setHueX(computeHueX(n))
  }

  function onSaturationChange(n) {
    setAnimate(true)
    setSquare([n, square[1]])
    setSquareXY(computeSquareXY(n, square[1]))
  }

  function onLightnessChange(n) {
    setAnimate(true)
    setSquare([square[0], n])
    setSquareXY(computeSquareXY(square[0], n))
  }

  // TODO
  // add saved color functions

  return (
    <>
      <PickerWrapper color={color}>
        <div className='swatch' onClick={() => setShow(true)} />
        <Modal modal={modal} show={show} onClose={() => setShow(false)}>
          <PickerOuter>
            <PickerInner>
              <Square
                hue={hue}
                squareXY={squareXY}
                offsetTop={offsetTop}
                offsetLeft={offsetLeft}
                animate={animate}
                setSquare={setSquare}
                setSquareXY={setSquareXY}
                setAnimate={setAnimate}
              />
              <Hue
                hueX={hueX}
                offsetLeft={offsetLeft}
                animate={animate}
                setHueX={setHueX}
                setHue={setHue}
                setAnimate={setAnimate}
              />
              <Inputs>
                <Input
                  label='H'
                  value={hue}
                  min={0}
                  max={360}
                  defaultValue={180}
                  setValue={onHueChange}
                />
                <Input
                  label='S'
                  value={square[0]}
                  min={0}
                  max={100}
                  defaultValue={100}
                  setValue={onSaturationChange}
                />
                <Input
                  label='L'
                  value={square[1]}
                  min={0}
                  max={100}
                  defaultValue={50}
                  setValue={onLightnessChange}
                />
              </Inputs>
              <Colors>                {colors.map((c, i) => (                  <Color                    key={i}                    color={c}                    onClick={() => onColorClick(c)}                    onContextMenu={e => removeColor(e, i)}                  />                ))}                <AddColor onClick={addColor}>+</AddColor>              </Colors>            </PickerInner>
          </PickerOuter>
        </Modal>
      </PickerWrapper>
    </>
  )
}

export default Picker

The update UI should look like this.

picker 1

Functionality

Add Color

Find the // TODO comment just above the return statement. This is where we will insert functions to manipulate our saved colors. The logical first step is to create a function that adds a new color. The Picker already has a hook that updates the current color state variable any time the Hue, Saturation or Lightness changes. To add a color the function needs to find the first open index in the colors array. This is done by passing a callback to Array.findIndex that returns the first index found with a falsey value. In other words, the first empty string. If the colors array is full of valid colors -1 is returned. Once the index is found we make a copy of colors so state isn't mutated. All that is left to do is assign the value at index to color and call setColors.

Picker.js
function addColor() {
  let index = colors.findIndex(c => !c)
  if (index !== -1) {
    let newColors = colors.slice()
    newColors[index] = color
    setColors(newColors)
  }
}

Remove Color

To remove a color the right click event can be used. Its probably a good idea to confirm this action with the user before permanently removing the color. This can be done by assigning a value to window.confirm. Also calling e.preventDefault() stops the normal context menu from popping up. If the user confirms removal the colors array is copied once again. The Array.splice method replaces the current color with an empty string. In this case, the event as well as the index from the Array.map function are both passed as arguments.

Picker.js
function removeColor(e, i) {
  e.preventDefault()
  let shouldRemove = window.confirm('Delete color?')
  if (shouldRemove) {
    let newColors = colors.slice()
    newColors.splice(i, 1, '')
    setColors(newColors)
  }
}

Click Color

When a color is clicked the Picker will shift to the correct values and the HSL colors string will be copied to the clipboard. The function takes a color as a parameter and only executes its logic if the color is truthy. In other words, if the color square clicked has a value of "" nothing happens. This first step is to parse the string being passed into it's individual values. parseHSL should be added to utils.js and imported into Picker.js.

Picker.js
function onColorClick(c) {
  if (c) {
    let [h, s, l] = parseHSL(c)
    onHueChange(h)
    setSquare([s, l])
    setSquareXY(computeSquareXY(s, l))
    copyToClipboard(c)
  }
}

This utility function uses a Regular Expression with Array.replace to effectively remove any character that is not a digit or a comma. The remaining string is then split into an array and then mapped to numbers. To visualize the process image: "hsla(h, s%, l%, a)" -> [h, s, l, a].

utils.js
export const parseHSL = color =>
  color
    .replace(/[^\d,]/g, '')
    .split(',')
    .map(Number)

Existing functions can now be used to set the various positions and values required by Picker. Finally, another utilty function copies the color string to the clipboard. This should also be added to utils.js and imported into Picker.js.

utils.js
export const copyToClipboard = input => {
  const el = document.createElement('textarea')
  el.value = input
  el.setAttribute('readonly', '')
  el.style.contain = 'strict'
  el.style.position = 'absolute'
  el.style.left = '-9999px'
  el.style.fontSize = '12pt'

  const selection = document.getSelection()
  let originalRange = false
  if (selection.rangeCount > 0) {
    originalRange = selection.getRangeAt(0)
  }

  document.body.appendChild(el)
  el.select()
  el.selectionStart = 0
  el.selectionEnd = input.length

  let success = false
  try {
    success = document.execCommand('copy')
  } catch (err) {}

  document.body.removeChild(el)

  if (originalRange) {
    selection.removeAllRanges()
    selection.addRange(originalRange)
  }

  return success
}

Color Picker in Action

At this point the saved colors addition should be fully functional. Try adding, removing and clicking on saved colors to make sure everything is working as intended. The only catch is that when the page is reloaded (Ctl/Cmd + R) the saved colors disappear. Enter Local Storage. The following code should be inserted at the first TODO so it runs before everything else.

Picker.js
useEffect(() => {
  let defaultColors = Array.from({ length: 5 }, () => '')
  let initalColors = JSON.parse(window.localStorage.getItem('colors')) || defaultColors
  setColors(initalColors)
}, [])

useEffect(() => {
  window.localStorage.setItem('colors', JSON.stringify(colors))
}, [colors])

The first hook is only run when the app loads. It declares defaultColors which should look familiar. Then localStorage.getItem is called with the key colors. This asking Local Storage for the value associated with colors. Since all data within Local Storage is stored as a string we must wrap the request in JSON.parse. The logical OR is triggered when null is returned from Local Storage. null will be returned the first time the app runs and also if the colors key in Local Storage is cleared. Passing [] to useEffect means this hook will only run once. Check the Application tab of Chrome DevTools and then select Local Storage in the left hand column to see the contents. Each web page has its own separate storage area.

picker 2

The second hook fires any time the value of colors changes. In this case localStorage.setItem is called with the key as the first argument and the value for that key as the second argument. JSON.stringify is used here to ensure the data is stored correctly. The window object can be omitted since JavaScript automaticaly tries to find variables in this scope.


The finsihed product of this article:

Conclusion

Everything should be working properly now. The following is a checklist of features that the Picker should have.

  • colors should persist when page is reloaded
  • Hue and Square should animate to the new color when a saved color is left clicked
  • the HSL value of a saved color should be copied to the clipboard when left clicked
  • a right click should open a Confirm window asking if the user wants to remove color
  • canceling Confirm should keep the color
  • okaying Confirm should remove the color

Here a couple ideas to expand on this article.

  • add a reset button that clears the entire colors key
  • add more saved color slots that can be rotated though 5 at a time
  • come up with a better delete color UI
  • add the ability to save multiple 5 color palettes that can then be loaded individually from a drop down
  • let the user name these palettes
  • add HEX or RGB or HSL formatted color string to the UI
  • make these strings one click copy
  • add all formats with a way to cycle through them
  • add UI that lets the user know something was copied