React vs. Ember: State Management

This post is the second in a series on React (and React’s ecosystem) versus Ember. The previous post discussed event handling, but you don’t need to read it to follow along with this post. All the posts in this series make the same general assumptions about the reader’s knowledge of SPAs, ECMAScript, and so on.

Over time, managing application state can easily become complicated, so a good foundation to build upon is important. If you don’t know the ways state can be managed, you can unintentionally create a situation in which there is a lack of testability and greatly reduced maintainability. (I’ll cover testing in the next segment of this series.)

This post demonstrates two ways of managing application state: with and without Redux. I also discuss and what using or not using Redux means in both the React and Ember ecosystems. (For a refresher on why anyone would want to use Redux, read the motivation page from Redux’s website.)  By comparing how state management works in React and Ember both with and without Redux, you’ll see the differences and similarities in each approach.

Because Redux is just JavaScript, the differences in usage between the React and Ember are negligible. In fact, as shown in the repo folder for this blog post, the React and Ember Redux implementations use the exact same redux-store folder.

All the projects in this blog post implement TodoMVC, which is a simple to-do app with some styling and predefined behavior. The code can be viewed here.

The custom redux layout

Officially, Redux has no opinion on code structure. The most commonly seen structure is the ‘Rails style‘, in which an actions folder and a reducers folder segregate your feature code. Although this approach is easy to implement, it’s not suitable for maintainability and discovery.

The following structure is the ‘ducks‘ structure mentioned in the Redux docs. This structure is really a feature-based domain-concept layout that focuses on grouping related behavior instead of grouping files by type (all actions in an ‘actions’ folder, for example).

To see implementation details, specifically for managing imports and (re)exports, you can browse through the code here.

redux-store/
│
│   # Re-exports the main things for public-api and redux-store creation.
├── index.ts
│   # Sets up devtools.
├── enhancers.ts
│   # Sets up sagas, thunks, logging, etc.
├── middleware.ts
│   # All reducers are combined here.
│   # This is the only place where combineReducers is called,
│   # and this defines the top level 'State' type.
├── reducers.ts
│
│   # A domain concept:
└── todos/
    │
    │   # Re-exports the public api things (action creators, the reducer).
    │   # Also defines the 'ActionTypes' type, which constrains the
    │   # reducer for todos to a fixed set of types for each possible
    │   # action available on the todos 'namespace'
    ├── index.ts
    │   # Selectors are helper functions that retrieve data out of the global
    │   # redux state. These should be pure functions, just like the reducers.
    ├── selectors.ts
    │   # Defines the domain concept namespace, types, and initial state.
    ├── shared.ts
    │
    └── actions/
        │
        │   # Each action contains everything that is needed for
        │   # a particular behavior.
        │   # - action type constant
        │   # - action creator
        │   # - reducer
        │   # - sagas (if being used)
        ├── add.ts
        ├── clear-completed.ts
        ├── complete.ts
        ├── destroy.ts
        ├── edit.ts
            ... etc



Redux: for both React and Ember

Redux is vanilla JavaScript, so it can be used with any library. You don’t even have to use Redux in a frontend single-page app context.

The parts that do differ and tie Redux into the frontend come from the corresponding packages: react-redux and ember-redux. These packages provide convenience and ease of setup by making assumptions about the development environment and providing some general abstractions and configurations.

Next is a comparison of the similarities and differences of using Redux in both React and Ember. React will be on the left (first on mobile), and Ember on the right (after React on mobile).

The following example shows usage without a container. Or rather, because naming in programming is subjective, it defines a container that is used to render a list of things.  The reason these aren’t traditional containers is because they each contain a template and that template is somewhat coupled to the rendered content (ul has lis). The real takeaway here is that the @connect usage is the exact same in both React and Ember.

src/ui/components/todo-list.tsx

// Imports omitted
const mapStateToProps = (state: State) => ({
  todos: list(state)
});

@connect(mapStateToProps)
export default class TodoList extends React.Component<Props> {
  render() {
    const { todos } = this.props;

    return (
      <ul className='todo-list'>
        {todos.map((t, i) => <TodoItem key={i} todo={t} />)}
      </ul>
    );
  }
}

src/ui/components/todo-list/{ component.ts | template.hbs }

// Imports omitted
const stateToComputed = (state: State) => ({
  todos: list(state)
});

@connect(stateToComputed)
export default class TodoListComponent extends Component {
  tagName = 'ul';
  classNames = ['todo-list'];
}
{{#each todos as |todo|}}
  <TodoItem @todo={{todo}} />
{{/each}}

This next example demonstrates how you might abstract away all store/state logic into a container. This approach is beneficial because the component can be unit tested without having to create the entire store.

You can enhance composability if a display or container component is generic enough to mix and match with other display and container components. Higher-order components fit in to this category of containers, but that’s a topic for another time.

src/ui/components/todo/index.tsx

import * as React from 'react';
import { connect } from 'react-redux';

import { edit, destroy, toggle } from '@store/todos';

import TodoDisplay from './display';

const mapDispatchToProps = (dispatch) => ({
  destroyTodo: (id: number) => dispatch(destroy(id)),
  toggleCompletion: (id: number) => dispatch(toggle(id)),
  editTodo: (id: number, text: string) => dispatch(edit(id, text))
});

export default connect(null, mapDispatchToProps)(TodoDisplay);

src/ui/components/todo/display.tsx

// File heavily abbreviated

export default class TodoDisplay extends React.Component<Props, State> {
  state = { editing: false };

  didFinishEditing = (e:  React.FocusEvent<HTMLInputElement>) => {
    const { editTodo, todo: { id } } = this.props;

    const text = e.target.value;

    editTodo(id, text);
    this.setState({ editing: false });
  }

  didDoubleClickLabel = () => {
    this.setState({ editing: true });
  }

  // ... Other action handlers ...

  render() {
    // Actions retrieved from props from the container
    const { todo, destroyTodo, toggleCompletion } = this.props;

    // ... Template omitted
  }
}

src/ui/components/todo-item/{ component.ts | template.hbs }

import Component from '@ember/component';
import { action } from '@ember-decorators/object';

import { connect } from 'ember-redux';
import { edit, destroy, toggle } from 'example-app/src/redux-store/todos';

const dispatchToActions = {
  deleteTodo: destroy,
  completeTodo: toggle,
  editTodo: edit
}

@connect(null, dispatchToActions)
export default class TodoItemContainer extends Component {
  tagName = 'li';
  editing = false;
  classNameBindings = ['todo.completed', 'editing'];

   // Local-state management omitted 
}
<TodoDisplay
  @todo={{todo}}
  @props={{hash
    deleteTodo=(action "deleteTodo" todo.id)
    completeTodo=(action "completeTodo" todo.id)
    editTodo=(action "editTodo" todo.id)
  }}
/>

src/ui/components/todo-item/display{ component.ts | template.hbs }

export default class TodoItemDisplay extends Component {

  @action
  didClickLabel() {
    this.props.startEditing();
    this.send('focusInput');
  }

  @action
  didFinishEditing(e: KeyboardEvent) {
    const target = (e.target as HTMLInputElement);
    const text = target.value;

    this.props.editTodo(text);
    this.props.doneEditing();
  }
}

Without Redux

React

In React, Redux is the go-to for application state management, but the Context API (which is a fairly new API within React) allows for sensible state-management without additional dependencies. Specifically, the Context API allows state to be shared across multiple components.  Within this API are contexts, which can define siloed behavior and be used by components. Contexts are somewhat similar to Ember’s services (services will be covered below). However, compared to services, contexts have some restrictions because they must have a Provider and a Consumer.

The Provider provides the configured context to all children in the tree below the Provider component invocation. The Consumer allows children of the Consumer in the immediate rendering context to access the Context‘s data.

In the following example, you see how the TodoMVC app would be managed and interact with the todos state with a context instead of Redux.

Instead of actions, reducers, and so on, a context (TodosContext) is configured and given a value during invocation-time.

The Application component becomes the state manager for this particular app.

src/ui/application.tsx

export default class Application extends React.Component<{}, State> {
  constructor(props) {
    super(props);

    this.state = {
      todos: [],
      clearCompleted: this.clearCompleted,
      // ...
    }
  }

  clearCompleted = () => {
    const todos = this.state.todos.filter(t => !t.completed);

    this.setState({ todos });
  }

  // ... Other actions omitted

  render() {
    return (
      <TodosContext.Provider value={this.state}>
        <TodoMVC />
      </TodosContext.Provider>
    );
  }
}

The following snippet shows how to use the context from any descendant in the tree.

The Consumer expects a function, which can use the shorthand argument deconstruction syntax to pull out only what is needed for that render.

src/ui/components/header/index.tsx

import * as React from 'react';

import HeaderDisplay from './display';
import { TodosContext } from '@contexts/todos';

export default class HeaderContainer extends React.Component {
  render() {
    return (
      <TodosContext.Consumer>
        {({ add }) => <HeaderDisplay addTodo={add} />}
      </TodosContext.Consumer>
    );
  }
}

Ember

By default, Ember comes with a couple of tools for managing application-level. The most common solution for application state is the service. Services are similar to the Context API that React provides (shown above).

However, unlike contexts in React, Ember services don’t need to be provided to child components, which is because Ember supports dependency injection, allowing for easier testing and less boilerplate when connecting pieces of the app together.

In addition to the provided state management is the supplemental package, ember-data. It provides a frontend ORM for managing data models that can be backed by varying data sources via adapters and serializers. In the following sample code, ember-data is only used to manage the list of to-dos locally in the browser.

/src/data/models/todo.ts

import Model from 'ember-data/model';
import { attr } from '@ember-decorators/data';

export default class Todo extends Model {
  @attr text?: string;
  @attr completed?: boolean;
}

Below is a service that abstracts the ember-data store and provides convenience functions for the TodoMVC problem space.

/src/services/todos.ts

export default class TodosService extends Service {
  @service store!: DS.Store;

  find(id: ID): Todo | null {
    return this.store.peekRecord('todo', id);
  }

  destroyTodo(id: ID) {
    const record = this.find(id);

    if (record) {
      record.deleteRecord();
    }
  }

  // other functions omitted
}

The @service decorator allows the Header component (below) to use the TodosService via dependency injection.

/src/ui/components/header/component.ts

import Component from '@ember/component';
import { service } from '@ember-decorators/service';
import { action } from '@ember-decorators/object';

import TodosService from 'example-app/services/todos';

export default class Header extends Component {
  @service todos!: TodosService;

  text = '';

  @action
  didSubmit(this: Header) {
    this.todos.add(this.text);
    this.set('text', '');
  }
}

Routing is the last piece of the built-in state management that Ember provides. In this version of the app, the routing is responsible for which set of to-dos are being viewed: all, active, or completed. You don’t have to use routes to filter these lists; alternatively, you could set up a series of computed properties with the filter value as the dependent key.

/src/ui/routes/completed/{ route.ts | template.hbs }

import DS from 'ember-data';
import Route from '@ember/routing/route';

import Todo from 'example-app/ui/data/models/todo';

export default class CompletedRoute extends Route {
  store!: DS.Store;

  model() {
    return this.store
      .peekAll('todo')
      .filter((todo: Todo) => todo.completed);
  }
}
<section class='main'>
  <TodoList @todos={{model}} />
</section>

<Footer @todos={{model}} />

State Management: Choose what best solves your problem

Both React and Ember provide state management out of the box, but Redux provides some shnazzy debugging capabilities (time-travel) due to its immutable nature of handling changes. The predicable and traversable state changes bring sanity to debugging when multiple areas of an app may be trying to change something at relatively the same time. On the flip side, many people have had issues with Redux’s performance with large sets of data.

As with all things, using the right tool for the job is important. Not everything needs to be in Redux.  Using Redux doesn’t mean you need to avoid other forms of state-management.  For example, when you’re working with a couple of form fields, and you don’t care about the state of those fields in other components, using Redux to manage the values and onChange events would cause unnecessary indirection and complication without any benefit.

In other situations, you may need all different kinds of state management. You might need services or contexts to manage computed values. Redux could control things like whether or not the sidebars in your app are popped open. Redux could also supplement some other state-management technique for interacting with websockets. There’s no wrong answer . . . unless the answer makes your life difficult.

It’s all about the Developer Experience (DX).

So do what makes your life easier.


In order to try to stay focused, I made a lot of assumptions about the readers’ knowledge.
If you want to know more about anything mentioned in this post, feel free to tweet at me @NullVoxPopuli.

Published July 10th, 2018 by:
  • Preston Sego