slonoed

Using symbols in Redux

August 27, 2017 • ☕️ 7 min readЧитать на русском

There are only two hard things in Computer Science: cache invalidation and naming things.

— Phil Karlton

When doing frontend cache invalidation, there is not such a huge problem. You can always ask a server for fresh data. However, it is not always good to pass data via a network, when a client already has this data. The only thing you need to check is this information relevant.

Besides some cases require knowledge about data status and depend on context. Example: a list of popular blog posts can contain the elements which have already been in a cache for few hours. However, when you visit post page, you need to fresh the data.

Application

Let’s take a look at two pages: the list of articles page and the article page.

Data structure

Each article is an object.

{id: 1, title: 'Songs recorded by Madonna'}

All server interaction are wrapped into API with two methods:

  • getPosts() — returns a Promise with array of articles
  • getPost(id) — returns a Promise with the article by ID

Action creators

The code is pretty simple and generates only two types of actions: receiving a list and receiving an article.

import api from '../api'
import { POSTS_LOADED, POST_LOADED } from '../constants/ActionTypes';

// Load all articles
export function loadPostsList() {
  return dispatch => {
    api.getPosts()
    .then(posts => {
       dispatch({type: POSTS_LOADED, posts})
    })
  }
}

// Загрузить один пост
export function loadPost(id) {
  return dispatch => {
    api.getPost(id)
    .then(post => {
       dispatch({type: POST_LOADED, post})
    })
  }
}

State

The reducer code is also simple. Notice that articles are stored in object instead of array. Keys in the object are IDs of articles. This normalized way allows fast search — O(1).

import { POSTS_LOADED, POST_LOADED } from '../constants/ActionTypes';

export default function counter(state = {}, action) {
  switch (action.type) {

    case POSTS_LOADED:
      // Convert array to object with keys-ids
      return action.posts
        .reduce(
          (posts, post) => ({...posts, [post.id]: post}),
        {})
    case POST_LOADED:
      // Replace article by ID
      return {
        ...state,
        [action.post.id]: action.post
      }
    default:
      return state;
  }
}

List component

import React, {Component} from 'react';
import {connect} from 'react-redux';
import {Link} from 'react-router';
import {loadPostsList} from '../actions/posts'

const PostLink = ({post}) => <li>
  <Link to={`/posts/${post.id}`}>
    {post.title}
  </Link>
</li>

class App extends Component {
  componentDidMount() {
    // After mounting load all articles
    this.props.loadPostsList()
  }

  render() {
    const {posts} = this.props

    // Until articles loaded — show preloader
    if (!posts.length) {
      return <b>Loading...</b>
    }

    return <ul>
      {posts.map(post => <PostLink post={post}/>)}
    </ul>
  }
}

export default connect(
  state => ({
    posts: Object.values(state.posts)
  }),
  {loadPostsList}
)(App);

Article component

The component receives ID from a router and renders the corresponding article or shows preloader if the article is not in the state.

When component appears, it fetches data from the server. If the article is already in the state, the component renders it, and after new data arrives, the component shows a fresh version.

import React, {Component} from 'react'
import {connect} from 'react-redux'
import {loadPost} from '../actions/posts'

class PostPage extends Component {
  componentDidMount() {
    // this.props.params from react-router
    // Depends on routing system the process of getting ID can be
    // different
    this.props.loadPost(this.props.params.id)
  }
  render() {
    const {post} = this.props

    // Show preloader if no article in state
    if (!post) {
      return <b>Loading...</b>
    }

    return <div>
      <h1>{post.title}</h1>
    </div>
  }
}

export default connect((state, props) => ({
  post: state.posts[props.params.id]
}), {loadPost})(PostPage)

Checking cache

Assume we want to show only reliable info. On the article page we want to show data received one minute ago or later.

If data is old, we can notify a user about it while fetching update. Alternatively, to show preloader.

Implementation

For this requirement, we need to store info about time the datum was fetched from the server. There are few options for that.

Separate state

The most obvious and straightforward variant is to keep this timestamp in the separate state object. It can be just map (ID -> Date).

In this case, you have two places with a coupled state which you need to synchronize. Also each time you need timestamp you have to pass it through props.

Field inside data item

Another way to store timestamp is to store it inside the article.

export default function counter(state = {}, action) {
  switch (action.type) {
    case POSTS_LOADED:
      return action.posts
        .reduce(
          (posts, post) => ({
            ...posts,
            [post.id]: {
              ...post,
              updatedAt: new Date()
            },
          }),
        {})
    case POST_LOADED:
      return {
        ...state,
        [action.post.id]: {
          ...action.post,
          updatedAt: new Date()
        }
      }
    default:
      return state;
  }
}

You can always retrieve a timestamp from the article. However, it leads to some issues:

  • Overwriting the field. You have to avoid this field in server data
  • Field appears in iteration. If you use Object.keys (ie for showing all fields in table) you also show this field.

WeakMap

WeakMap allows you to store link from one object (data entity) to another object (metadata). Moreover, GC removes metadata if entity is removed.

Thus we can create separate module which contains info about last update with API:

const updatedMap = new WeakMap()

export function saveUpdatedAt(item) {
  updatedMap.set(item, new Date())
}

// Returns object age in milliseconds
export function getAge(item) {
  return Date.now() - updatedMap.get(item)
}

This method has a downside that you loose when copying the object.

const object = {field: 'yip yip'}

saveUpdatedAt(object)

const copy = {...object}

getAge(copy) // doesn't make any sense

Symbol

I want to talk about a way that uses one of the new features of language — symbols.

It is similar to the approach with filed inside a object. However, instead of a string, we use a Symbol for a key.

const updatedAtKey = Symbol()

export default function counter(state = {}, action) {
  switch (action.type) {
    case POSTS_LOADED:
      return action.posts
        .reduce(
          (posts, post) => ({
            ...posts,
            [post.id]: {
              ...post,
              [updatedAtKey]: new Date()
            },
          }),
        {})
    case POST_LOADED:
      return {
        ...state,
        [action.post.id]: {
          ...action.post,
          [updatedAtKey]: new Date()
        }
      }
    default:
      return state;
  }
}

Using symbols, we remove both issues (naming collision and iteration). A component needs to have the symbol for getting the value.

const updatedAt = post[updatedAtKey]

It is a good idea to hide implementation details inside the module and prevent direct usage from the component or reducer.

// meta.js

const updatedAtKey = Symbol()

// Returns object copy with timestamp field
export function withUpdatedAt(item) {
  return {
    ...item,
    [updatedAtKey]: new Date()
  }
}

// Return object age in milliseconds
export function getAge(item) {
  return Date.now() - item[updatedAtKey]
}

Now update the reducer:

export default function counter(state = [], action) {
  switch (action.type) {
    case POSTS_LOADED:
      return action.posts
        .reduce(
          (posts, post) => ({...posts, [post.id]: withUpdatedAt(post)}),
        {})
    case POST_LOADED:
      return {
        ...state,
        [action.post.id]: withUpdatedAt(action.post)
      }
    default:
      return state;
  }
}

And the component:

class PostPage extends Component {
  //...

  render() {
    const {post} = this.props

    if (!post) {
      return <b>Loading...</b>
    }

    const isFresh = getAge(post) < 5000

    return <div>
      <h1>{post.title}</h1>
      {isFresh || <i>Data is obsolete. Updating...</i> }
    </div>
  }
}

Symbols essentials

As any implicit approach, meta information can lead to implicit errors. Example: while using spread operator symbol keyed field is copied.

const key = Symbol()
const source = {
  regularField: 'hello',
  [key]: 'world'
}

const target = {...source}

console.log(target.regularField + ' '+ target[key]) // hello world

However, if you use some library to clone an object, these fields can be lost.

Also, these fields would be omitted when serialized.

Other languages

OCaml gives explicit way to define wrapper types for data.

type user = {
  id: int;
  name: string;
}
type timestamp = int
type 'a entity =
  | Empty
  | Loading of 'a
  | InState of 'a * timestamp

let user =
  InState ({ id = 123; name = "Zohan" }, 1504470691)

Conclusion

This usage is one of many for symbols-keys for metadata. Field with updated timestamp can be used, for example, in cation creators to check whether a data entity needs an update from server or not.

Symbols give powerful and therefore dangerous instrument. If you decide to use them for storing data, you need to understand clearly what you are doing and safely write code to keep program without errors even if you lost metadata.


If you have any questions or want to discuss this text, please reach me in twitter.