Opinionated Comparison of React, Angular2, and Aurelia

Jeff Schnitzer edited this page Apr 7, 2017 · 14 revisions

March 29, 2017

I have been doing some form of frontend development since the early 90s. Back in the days of MFC we built GUIs out of components - controls, panels, frames, dialogs, etc. Getting the faux-3D to line up was hard work but you could assemble complex UIs by wiring together simpler self-contained widgets. There was even a thriving commercial market for "exotic" components like trees and data tables.

Then the web happened and suddenly everything was a form. It was easy but it produced the kind of UIs that only the IRS could love. Pages were giant jumbled messes of template and jQuery. The best thing I can say about this era is that users had low expectations.

Thinking positively, frontend development means that if you hate the tools, all you have to do is wait two years. My Angular1 knowledge is past it's expiration date and ready to be tossed like sour milk -- which is an apt description of the taste it left in my mouth. So I'm not disappointed to be back at the bottom of the learning curve. Again.

Thankfully, components are back in fashion with the latest crop of web frontend toolkits. I'm building a greenfield single-page app. Which to spend the next two years with?

I picked three frameworks and built spike solutions in each of them: React, Angular2, and Aurelia. My spike was "implement the first couple screens of my app", including a URL that looks like /things/123/subthings/456/detail. It's like a torture chamber for child routers.

What follows is a stream of opinions I formed going through this process. I didn't become an expert in any of these three frameworks, so feel free to yell at me if some of my code examples are "doing it wrong".

Documentation

React's documentation is amazing -- and mercifully short. React is surprisingly simple and doesn't really do a lot of stuff. Expect to achieve competence in an afternoon.

Angular2's documentation is also amazing -- but it really needs to be. The API footprint is large and there are many concepts to learn. Fortunately there is a tutorial for seemingly every eventuality, although some of them are slightly out of date. Don't expect to become an angular expert overnight.

Aurelia is a mixed bag. Aurelia is like a more elegant, easier to understand Angular2. In places where Angular2 feels overengineered, Aurelia just "does the simple thing". This helps compensate for generally inadequate documentation, but not entirely. The Aurelia docs aren't terrible per-se, but I often found myself wishing for tutorials that don't exist. I found it helpful to read the Angular2 docs to resolve conceptual issues.

Typescript Support

Components should have clear, unambigous interfaces. They can be squishy on the inside, but keep strong explicit types at the boundaries! Typescript is great.

Unsurprisingly, Angular2 is the clear winner here since the framework is developed in Typescript. All libraries that I tried had good type bindings. IntelliJ never stumbled.

Aurelia's Typescript support is OK. There are Typescript bindings for everything, but most of the framework itself is not developed in Typescript so clicking through or debugging into the source code is always disappointing. And you'll need to because the documentation is spartan. I can't really comment on Typescript support in the broader Aurelia ecosystem because there really isn't one.

I expected React's Typescript story to be terrible, but I was actually pleasantly surprised. TSX support is great, and IntelliJ is wicked smart about correcting prop and state types. For example, if you write code like this:

interface Props {
    thing: Thing;
}
interface State {
    someValue: string;
}
export class ThingViewer extends React.Component<Props, State> {
}

...then IntelliJ will enforce the type of prop values both inside ThingViewer.render() and in all the places that render a <ThingViewer>.

On the other hand, React itself does not embrace Typescript and even recreates type checking as a runtime concern. Twice. And the React ecosystem tends to either lack typings or provide poor/broken typings.

Build system

Oh god, React wins hands down. Knowing absolutely nothing about webpack before this, I wrote a webpack config by hand (including proxy and sass) with the help of this and this. There's no magic and no magic necessary.

Angular2 comes with a CLI tool that hides the build process from you. That's good because the build is a huge crazy machine that mere mortals are not expected to understand. It mostly works out that way; I'm impressed that they transparently switched the underlying mechanism from SystemJS to Webpack not long ago. BUT... the proxy config was hard to figure out (it changed and it's poorly documented) and they generate the index.html (!) Some of us dynamically generate this for various reasons. You can just copy the important script tags into your html but it still seems weird and invasive.

Unfortunately, Aurelia's build is in rough shape. They provide a CLI tool that generates an assortment of gulp tasks that you check in with your project. The compiler 'watch mode' frequently crashes when you save a file with syntax errors (as IntelliJ's autosave often does), and restarting can take tens of seconds even with a small number of files. As an alternative to the CLI-generated project, you can fork a skeleton that uses webpack - but it's complicated and based on "easy-webpack" whose name is pure fiction. You know what's easy? React's webpack config.

Aurelia's team is apparently working on a new CLI based on webpack, due out later this year. It is needed.

Dependency Injection

Say your app has a snackbar. The snackbar has global state; components can trigger toasts at any time and these must stack gracefully. How does every component (and even non-ui libraries!) get a reference to the snackbar.toast() method?

Or perhaps you have an activity indicator at the top of your page; components need to be able to twiddle it such that "last one to leave turns the lights off".

Aurelia's DI framework figures out what to inject from the typescript declaration. All you need is the @autoinject annotation. Beautiful:

class SnackBar { /* nice boring class */ }

@autoinject
export class Mechanism {
    constructor(private snackbar: SnackBar) {}

    public activate() {
        // do something then...
        this.snackbar.toast("Mechanism activated!");
    }
}

Angular2's DI framework requires more configuration:

@Injectable()
class SnackBar { /* */ }

@Component({
  selector: 'mechanism',
  providers: [SnackBar],
  template: `...`
})
export class Mechanism {
    constructor(private snackbar: SnackBar) {}

    public activate() {
        // do something then...
        this.snackbar.toast("Mechanism activated!");
    }
}

I declared the type of the snackbar on the constructor, why do I need to declare it again as a provider? I get that you can do some interesting things with providers, but there's an obvious default and Aurelia figures it out somehow.

React doesn't have a great story here. Some options:

  1. Pass the snackbar and activityIndicator to all of your components as props. This is exactly as tedious as it sounds, and it doesn't address non-ui-component users like ajax fetch libraries.
  2. Pass an app prop to all of your components that contains a snackbar, activityIndicator, and any other globals. This is not much more satisfying than #1, although involves somewhat less typing.
  3. Save the global components off as global variables and just use them. Muddies up the component contracts and presents lifecycle problems - when exactly do those global variables get set?
  4. Use the context. This is just like dependency injection except that it has an incredibly awkward syntax. Wouldn't it be easier to just inject something on the constructor?
  5. Something something Redux something Flux something. This seems like an incredibly big leap to take and involves WAY more code than just calling a simple function on a component. I don't need redux yet.

This is disappointing but not fatal. I ended up using boring old global variables. When my ActivityIndicator component mounts, it saves itself off to a global. Any code can import busy and run busy.start() and busy.stop(). There's really only one place this happens - my wrapper around ajax fetching - but that isn't part of the component tree so it's not like I can pass a prop to it.

I'd love to hear what other React people do about this.

Component Encapsulation

I thought I was going to hate React's JSX/TSX. I don't. It's fugly but it allows one wonderful characteristic: A component is one JS/TS class. Want to include another component in your component? Just import it:

import {ThingDetail} from "./ThingDetail";

interface Props {
    things: Thing[];
}
export class ThingList extends React.Component<Props, undefined> {
    render() {
        return <div>
            {this.props.things.map(th => <ThingDetail key={th.id} thing={th} />)}
        </div>
    }
}

You're pretty much always thinking in Javascript. The mapping between element names and class names is just import! This is my favorite aspect of React. You can even command-click on them in IntelliJ and navigate to the exact class definition.

Aurelia uses a separate template file that looks more or less like every other web templating language you've ever used. By convention if your component class is in hello.ts, then its template is in hello.html and your component will be <hello/>. The actual name of the JS class seems to be irrelevant, which is weird since Aurelia isn't relying on a default export. There's some black magic going on behind the scenes.

<!-- thing-list.html -->
<template>
    <require from="thing-detail"/>
    <div>
        <thing-detail repeat.for="thing of things" thing.bind="thing"/>
    </div>
</template>
/* thing-list.ts */
export class ThingList {
    public things: Thing[];
}

I have mixed feelings about this. On one hand it feels nice and familiar to separate out code and html. On the other hand it's somewhat painful to go back and forth between thinking in JS and template, and the actual composition is driven by templates (it's not "the code determines the template" but "the template determines the code").

Angular2 is very similar to Aurelia, with more explicit configuration and funky characters in the template. You can put the template in a separate file, or for simpler components you can include the template inline:

/* thing-list.component.ts */
@Component({
    selector: 'thing-list',
    template: `<div><thing-detail *ngFor="let thing of things" [thing]="thing" /></div>`
})
export class ThingList {
    public things: Thing[];
}

With Angular2, all custom elements (ie <thing-detail>) get registered in advance. To me this is less direct than Aurelia's explicit <require> and significantly less direct than React's simple JS import. Which of my thousand component classes implemets <thing-detail>? No wonder the Angular2 community is picky about file naming conventions.

The funky characters are confusing at first but you get used to them. There's rhyme and reason to it (crudely: [] is "out", () is "in", [()] is bidirectional) but I don't think this would have been my choice.

Routing and Fetching

The one thing all three frameworks have in common is overly simplistic examples and generally poor documentation of how to route in the real world. Otherwise they are quite different.

Let's talk about the URL path /things/123/subthings. When you visit this path you expect some nested behaviors, going from outermost to innermost:

  • Some component renders the primary navigation chrome
    • Shows branding for your app
    • Things is highlighted in primary navigation
  • Some component fetches data for Thing#123 and renders secondary navigation chrome
    • Shows that you are looking at Thing#123
    • Subthings is highlighted in secondary navigation
  • Some component fetches the subthings of Thing#123 and renders a list in the main content area

We want to pause rendering (and maybe display something spinny) while we wait for server fetches.

This example should be front and center, but I couldn't find it. So here you go. This section is going to be long.

React's Routing

React's router is not really "React's router" - in line with "we're just a view renderer", the React team does not publish a router themselves. There are actually a couple options for routing in React, including a port of Angular's router. That said, the overwhelmingly popular answer is react-router.

React-router has gone through a lot of version churn. I used the (at the time) still-in-beta v4 router. This is good because it's clearly a better solution than prior versions. It's bad because all the tutorials and most of the online documentation refer to the old versions. Also there were no typings for v4 (since fixed).

With most Javascript routers, you define a configuration of routes up front and the main routing code figures out what to do. React-router v4 is much simpler. You can think of each <Route> as simply an element that renders conditionally when part of the URL bar path matches. Here's some code that should render /things/123/subthings, fetching data long the way:

export class App extends React.Component<undefined, undefined> {
    render() {
        return (
            <Router>
                <h1>Hello App</h1>
                <Route path="/" exact render={() => <Welcome />} />
                <Route path="/things" exact render={() => <ThingsList />} />
                <Route path="/things/:thingId" render={(props) => <ThingNav baseUrl={props.match.url} thingId={props.match.params.thingId} />} />
            </Router>
        );
    }
}
interface Props {
    baseUrl: string;
    thingId: string;
}
interface State {
    thing: Thing;
}
export class ThingNav extends React.Component<Props, State> {
    
    constructor(props: Props) {
        super(props);
        this.state = {thing: null};
        
        thingResource.fetchThing(props.thingId)
            .then(thing => this.setState({thing: thing}));
    }
    
    render() {
        const {thing} = this.state;
        const {baseUrl} = this.props;

        if (!thing)
            return null;
        
        return (
            <div>
                <h2>{thing.name}</h2>
                <Route path={`${baseUrl}`} exact render={() => <ThingDetail thing={thing} />} />
                <Route path={`${baseUrl}/subthings`} render={() => <Subthings thing={thing} />} />
            </div>
        );
    }
}
interface Props {
    thing: Thing;
}
interface State {
    subthings: Subthing[];
}
export class Subthings extends React.Component<Props, State> {
    
    constructor(props: Props) {
        super(props);
        this.state = {subthings: null};
        
        thingResource.fetchSubthings(props.thing.id)
            .then(subthings => this.setState({subthings: subthings}));
    }
    
    render() {
        const {subthings} = this.state;

        if (!subthings)
            return null;
        
        return (
            <div>
                <h3>The subthings:</h3>
                {subthings.map(sub => <SubthingDetail key={sub.id} subthing={sub} />) }
            </div>
        );
    }
}

Some thoughts about this:

  • Fetch for things/subthings will start when the component loads; each will render a blank content area (render returns null) until the content is fetched.
  • Passing the baseUrl all the way down is a PITA.
  • Most of the doc examples show <Route component="SomeComponent"/> which AFAICT doesn't allow you to pass properties to the subcomponent... which you almost always want to do. So pass a closure with the render prop.
  • I left out some essential code; sometimes, instead of a new componenent being created, your existing component will just receive new props via componentWillReceiveProps(). If you get a new thingId, you need to refetch the new Thing.
  • More layers is more pain. /things/123/subthings/456/blah is ouchy.

Aurelia's Routing

Here's more-or-less what the same code looks like using Aurelia:

<!-- app.html -->
<template>
    <h1>Hello App</h1>
    <router-view></router-view>
</template>
/** app.ts */
@autoinject
export class AppComponent {
    private router: Router;

    configureRouter(config: RouterConfiguration, router: Router): void {
        this.router = router;
        config.title = 'MyApp';
        config.options.root = '/';
        config.map([
            { route: '',                name: 'welcome',      moduleId: 'welcome' },
            { route: 'things',          name: 'things-list',  moduleId: 'things-list' },
            { route: 'things/:thingId', name: 'thing',        moduleId: 'thing' },
        ]);
    }
}
/** current.js */
export class Current {
    public thingId: string;
}
<!-- thing.html -->
<template>
    <h2>${thing.name}</h2>
    <router-view></router-view>
</template>
/** thing.ts */
@autoinject
export class ThingComponent {
    private router: Router;
    public thing: Thing;

    constructor(private thingResource: ThingResource, private current: Current) {}

    activate(params: any, routeConfig: any): Promise<any> {
        this.current.thingId = params.thingId;
        return this.thingResource.fetchThing(params.thingId)
            .then(thing => this.thing = thing);
    }

    configureRouter(config: RouterConfiguration, router: Router): void {
        this.router = router;
        config.map([
            { route: '',          name: 'thing-detail', moduleId: 'thing-detail' },
            { route: 'subthings', name: 'subthings',    moduleId: 'subthings' },
        ]);
    }
}
<!-- subthings.html -->
<template>
    <div repeat.for="subthing of subthings">${subthing.name}</div>
</template>
/** subthings.ts */
@autoinject
export class SubthingsComponent {
    private router: Router;
    public subthings: Subthing[];

    constructor(private thingResource: ThingResource, private current: Current) {}

    activate(params: any, routeConfig: any): Promise<any> {
        return this.thingResource.fetchSubthings(this.current.thingId)
            .then(subthings => this.subthings = subthings);
    }
}

This may seem convoluted but there's a lot to like here.

  • Aurelia's child routers are aware of the base context; I don't need to explicitly pass baseUrl down to each component.
  • The activate() method is called by the router before a transition and holds until the promise is resolved. This means that clicking a link to a new page works just like old-school page loads - the browser leaves old content intact on the page until new content is loaded and ready. With React's return null, page transitions immediately show a blank screen. I think Aurelia's UX is nicer.
  • I don't like the way that the thingId key is defined in one file (things.ts) and consumed in another (thing.ts). Magic property keys aren't enforced by the IDE/compiler, boo.
  • Is there a way of obtaining the thingId in the SubthingsComponent that is better than injecting a global Current context? I don't know.
  • Aurelia twiddles the html page title automatically so you end up with App > Thing > Subthing in the browser tab. This is kinda cool but if you have complicated routing you have to do some tricks to get the right elements in the list.
  • The documentation for child routing is terrible. I'm embarassed by how long it took me to work this out.
  • It's luxurious working with observable-based systems where you just change variables and rendering happens "magically". Changing state in React requires a ferocious amount of typing.

Angular2's Routing

Here's where I fess up and admit that I didn't finish the Angular2 spike, so I'm not going to paste my half-baked code. Angular2 routing feels vaguely similar to Aurelia routing, but there are some things I don't like about it:

  • Angular's routing seems to involve "one big routing config". Maybe you can break down child routers in subcomponents the way React or Aurelia do? It's not obvious from the examples. I want fully encapsulated components; if it's route-sensitive, that behavior should be hidden inside the component. One big config puts that behavior somewhere else.
  • Angular has an equivalent of activate(): resolvers and guards. They're a lot more complicated, yes. But the huge problem is that they are configured on the router, not in the component. The component has to get resolved data out of an untyped bag property on the router. LAME! Components should fully encapsulate their behavior, including fetch behavior.
  • You can still do React-style "render blank until the data is loaded" by wrapping your whole template in <div *ngIf="loaded">...</div>. Somehow this feels more intrusive than React's simple two-line guard condition, but it's still vastly better than configuring resolvers in some extra routing module that doesn't belong to your component.

I was super disappointed with this aspect of Angular2. Aurelia really nailed routing; Angular2's routing feels like a giant crazy machine bolted onto the side.

Ecosystem

React's ecosystem is huge, and seems to be where all the "cool kids" are playing these days (at least here in SF). Google react + just about anything and you'll find endless components. I found four mature material design implementations!

Angular2 is newer so the ecosystem isn't quite as big as React's, and with the comparatively steep learning curve it may never be the darling of startups. Oddly enough, Google-provided material design components have been slow to... materialize. But there are still plenty of component libraries, including ones published by larger corporations (see what Salesforce and Teradata are doing).

The Aurelia community is, unfortunately, tiny. Third-party component libraries just do not exist. You're pretty much on your own.

Conclusion

I agonized over this decision for a long time. There are things I like and dislike about each framework; none are universally better than the others. Ultimately I decided to go with React.

  • Angular2 should have been a shoo-in; I'm a Java guy who loves static types and dependency injection, plus I'm already familiar with Angular1. No brainer, right? But Angular just feels overengineered to me... and this is coming from someone that occasionally still has nice things to say about EJB. Maybe I wouldn't feel this way if I didn't have Aurelia to compare it to? Angular2 is a major improvement over Angular1.
  • Aurelia is pretty awesome, but there's not enough community around it. My team is small and doesn't have a professional web designer; I need other people's components. I also hit too many stumbling blocks building out my spike, requiring hours of painful research. I never did succeed at getting a dialog to open, show a progress bar, and close programmatically. I'm still using aurelia-fetch-client with React though, it's great.
  • React is the simplest, in both good and bad ways. It's pretty easy to understand how everything works, yeay. React requires quite a bit more code than the others, boo. Ultimately I decided I could live with the extra typing. And it should be a useful skill for consulting gigs.

There are a few more frameworks I would have loved to try, including Polymer and Vue.js. There just wasn't enough time to do a deep dive with all of them.

Coming up on maybe two months of working in React, I'm still reasonably satisfied. I didn't exactly fall in love with JSX/TSX but I've at least fallen in like with it. "One component == one JS class" makes it really fast and easy to decompose complex UIs into smaller components, so I'm getting a lot of code re-use on screens that do similar-ish things. I feel productive.

Overall, I'm not 100% sure I made the right choice, but I only have to live with it for two years.

discuss on HN

You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.