tgvashworth

Flight.js in 2016

26 Sep 2016

Way back in 2014 I wrote about how we use Flight.js in TweetDeck. Well, it’s safe to say that a few things have changed since then.

I recently gave a talk about what I have learned working on TweetDeck, where I mentioned a few of these things, but here they are for your reference and mine!

Mixins

Like React, we consider mixins harmful.

However, as they are almost the only way to extend Flight core in a modular way, we use them to extend our base components. No new mixins have been created for a long time, and the existing ones are slowly being replaced by higher-order components and utilities.

Nesting

I mentioned withTeardown in the previous post. This has now been renamed and open-sourced as withChildComponents!

Using it is pretty simple:

this.attachChild(
  ChildComponent,
  this.select('childSelector'),
  { ... }
);

State

State in Flight was managed in a number of inventive ways. You might see any or all of the following in a typical Flight component:

this.active = true;
this.$node.attr('data-active', true);
this.attr.active = true;
this.state = { active: true };

This was hard to debug, maintain and read. So we created withState, a mixin that takes inspiration from React to standardise state management in Flight.

It looks like this:

this.initialState({
  active: false
});

// ... some time later ...

this.mergeState({
  active: true
});

You can react to state changes with an advice hook:

this.after('stateChanged', this.render);

Data flow

Events are not a good primitive for data flow. They’re hard to follow and debug, and they’re impossible for a static analyser to help with.

Consider this unreadable example:

this.on("usersResponse", (e, response) => {
  if (response.requestId === this.id) {
    response.users.map(user => {
      this.trigger("tweetsRequest", {
        requestId: this.id,
        userId: user.id
      });
    });
  }
});

this.on("tweetsResponse", (e, response) => { /* ... */ });

this.trigger("usersRequest", { requestId: this.id });

All it really does is this:

Users.getAll()
  .then(users => Promise.all(
    users.map(user => Tweets.getByUser(user.id))
  ));

To make this possible, we switched to using RxJS observables plus some helper mixins (withResources, withObserve, and withObservableState).

Now, some typical data management code in TweetDeck might look like this:

// Poll the force-refresh endpoint
this.observe(this.getTimer())
    .flatMap(this.getVersion)
    .map(this.processVersion)
    .do(this.sendMetrics)
    .filter(this.shouldRefresh)
    .subscribe(this.refresh);

Much better!

Thanks to Andy for, ahem, observing that this needed improvement and, ahem, reacting to fix it. (Sorry).

Base components

To make this all standard within TweetDeck, we created a “base” component that is a foundation for all new components. They’re very easy to create:

export default component(function Base() {}, withState, withChildComponents);

And use:

import Base from './base';
export default Base.mixin(function MyComponent () { ... });

Connected Components

Most recently, we’ve been working on “connected” components that standardise linking the state of a parent component to a new props property of a child component.

This replicates another pattern from React that causes the child to re-render whenever new props appear, and the connect([mapStateToProps]) pattern from react-redux.

In use, it looks something like this:

const ConnectedComponent = this.connect(Component);

const ConnectedComponent = this.connect(Component, {
  mapStateToProps: (state, attr) => ({
    // ...
  })
});

You can imagine a ToggleButton component like this (using our UiBase as a base component):

const ToggleButton = UiBase.mixin(function toggleButton() {
  this.attributes({
    enabledClass: 'is-enabled'
  });

  this.componentDidInitialize = function () {
    this.on('click', () => this.trigger('toggleButtonClicked'));
  };

  this.render = function () {
    const {
      isEnabled = false,
      message = (isEnabled ? 'Disable' : 'Enable')
    } = this.props;

    this.$node
      .toggleClass(this.attr.enabledClass, isEnabled)
      .text(message);
  };
});

Things to notice are this.props, which is an object with values that define how the component should render, and the componentDidInitialize method which mirrors React’s componentDidMount.

In a parent component, connecting the component with a mapStateToProps implementation looks like this:

// Connect this component to the button using this.connect.
// mapStateToProps will be used to turn *this* component's state, plus the
// connected component's attrs, into a props object.
const ConnectedButton = this.connect(ToggleButton, {
  mapStateToProps: state => ({
    isEnabled: state.enabled
  })
});

And we can use the usual Flight patterns to initialize the component:

// this.attachChild connects the lifecycle of this component and
// the connected child.
this.attachChild(
  ConnectedButton,
  this.select('buttonSelector'),
  { enabledClass: 'enabled-button bg-red' }
);

We’ve found this pattern to be easier to test and pretty intuitive to developers who’ve used React and Redux before.

Next steps might include adding more lifecycle methods like getDefaultProps and runtime type-checking using prop validation.

Big, ahem, props to Aman for his work on this.

Why not just use React?

… or any other framework?

In the future we absolutely want to, but we have tens of thousands of lines of code and perhaps five distinct JavaScript styles already in the codebase. Adding another on top is confusing for everybody and, in my opinion, wildly inconsistent code is worse than using an old framework.

When a wholly new piece of UI is ready to be rewritten we will likely start using React, but we’ll first have to consider factors like bundle size, load performance, unit testing, and how we onboard new engineers.

For now, improving our existing framework using good ideas from elsewhere is fruitful and gives us a way to refactor our existing UI code without needing a big rewrite.