Angular User interface Programming Practice Web

At SketchUp I managed a user interface library, and the best practices that went with it, used by multiple teams on multiple different projects. We started off doing the wrong thing in quite a few places, and then had to tidy up the codebase so it was smaller and easier to maintain, with the common patterns and frustrations abstracted away. These are the things we landed on.

One theme runs all the way through: have a set of standards, and apply them very strictly. That needs good discipline in the team, and a good understanding of the reasons for the principles and how they fit together, which is its own challenge in a rapidly moving product with a growing team. In practice it worked really well.

One global stylesheet

We started with component CSS, where every component has its own stylesheet next to it:

.header { padding: 8px; }
.header h1 { font-size: 20px; margin: 0 0 8px 0; }
.header .buttons button { margin-right: 8px; }
One component's stylesheet, out of hundreds

We very quickly realised this wasn't a scalable solution. We got bored of typing padding: 8px on each and every component, and it created a lot of work for the designer, because he had to design each and every component and couldn't rely on a standard design system.

The solution was a global stylesheet, combined with a few tricks to tame the layout. The idea was to create a standard global stylesheet first of all, which styles every standard component the same way everywhere, and then have a certain set of exceptions. It turned out in practice that one of the exceptions we needed was custom sizes on components, and we did those with inline styles:

<project-preview style="height: 240px"></project-preview>
A custom size, as an inline style

This meant the designer didn't have to create pixel-perfect Figma diagrams with all the measurements on every time. Instead he could work at a higher level, with standard components, knowing that we'd get standard spacing, padding and colours everywhere.

It's better for the developers too. A team all writing their own CSS into components is hard to manage, but CSS is also hard for the developers. Sometimes it's just hard, not every developer is completely up to date with it, and it was one of people's least favourite things to deal with, possibly because of all the ways the CSS in different components interacts. With it managed in one global place and wrapped up in components, they generally never needed to deal with it, which was a bonus for them.

There were very few places where we really needed per-component styling. The huge benefit was that every component became simpler. Most of the CSS in the system disappeared completely, and the larger components, which were split across several files, had fewer of them, because no component needed a CSS file.

A component for everything

The templates were rapidly turning into <div> soup. The remedy was to abstract every element, right down to basic titles, subtitles and text, which wrap the plain h1, h2 and p tags. It was a complete abstraction, so developers were always working in the user interface library, with <ui-title>, <ui-subtitle> and <ui-text>, and never with the tags underneath.

Many of these components did little more than put a class on the custom element in the DOM, for the global stylesheet to style:

@Component({
    selector: 'ui-subtitle',
    template: '<h2><ng-content></ng-content></h2>',
    host: { class: 'ui-subtitle' },
})
export class UiSubtitleComponent {}
The subtitle component
.ui-subtitle h2 { font-size: 18px; font-weight: 600; margin: 0; }
The subtitle's style in the global stylesheet

A component that small made the code highly readable, and it ensured there was only one correct way of doing anything. A subtitle is a <ui-subtitle>, everywhere, and if subtitles need to change, they change in one place.

Spacing

Something that often catches people out is the spacing between components. If you put some invisible padding on the right of a button, so that the next button is the right distance away from it, you're starting to ask for trouble as you scale the application and reuse components. There'll be some places where you want more space between that button and some other button, and some where there's nothing next to it at all. Before this, the team was constantly chasing padding and spacing issues around the application, to the great frustration of the design team.

So I have a simple rule for this: no component has any invisible padding around its edge. If it has a background, it can have padding inside the background, but the visible edge of the component never tries to push anything away from itself. Instead, parent components impose spacing on their immediate children.

Spacing on the button, and spacing from the row
Spacing on the button, and spacing from the row

In practice this meant three layout components, <ui-row>, <ui-column> and <ui-grid>. This was before flexbox's gap worked in every browser, so what they did was put padding on each of their immediate children, on the bottom in a column, and then take it off the very last one:

ui-column { display: block; }
ui-column > * { display: block; padding-bottom: 16px; }
ui-column > *:last-child { padding-bottom: 0; }

ui-row { display: flex; align-items: center; }
ui-row > * { padding-right: 8px; }
ui-row > *:last-child { padding-right: 0; }
A column and a row spacing their children, in the global stylesheet

The padding goes on the child's own custom element, the one Angular puts in the DOM, which is outside anything the child draws. A button's background is inside its <ui-button>, so the padding is invisible space between it and the next one, which is exactly where the row wants it. The grid spaces its children the same way, in both directions.

What a template looks like

The combination of wrapping all the basic HTML tags we needed, the layout components and the global stylesheet made the template for any component look really simple. Here's an example of how one could look:

<ui-column>
    <ui-title class="centre">Project settings</ui-title>
    <ui-text>Changes are saved as soon as you make them.</ui-text>
    <ui-subtitle>Members</ui-subtitle>
    <ui-grid>
        <project-member-card *ngFor="let member of members" [member]="member"></project-member-card>
    </ui-grid>
    <ui-row>
        <ui-button (click)="invite()">Invite</ui-button>
        <ui-button (click)="leave()">Leave project</ui-button>
    </ui-row>
</ui-column>
A component's template, made only of components

There are no <div>s, no styles and almost no classes, just the components. The only place we needed classes was to override a default. A title is on the left by default, for instance, but sometimes we wanted a centred title, so it gets class="centre", and the global stylesheet has a rule for that:

ui-title.centre h1 { text-align: center; }
Centring a title

Loading

Loading data is one of the common patterns that's best abstracted away completely. One component, <ui-loader>, can wrap any part of the page, and a pipe, httpGet, fetches data straight from the template. While anything inside the loader is loading, the loader hides its content and locks it:

<ui-loader>
    <ng-container *ngIf="('/api/projects/' + projectId) | httpGet as project">
        <ui-title>{{ project.name }}</ui-title>
        <project-model-list [projectId]="projectId"></project-model-list>
    </ng-container>
</ui-loader>
Loading a project, with no loading code in the component

The component has no loading flags, no subscriptions and no lifecycle methods, and a child component that loads its own data keeps the loader locked until it's finished too. How the loader and the pipe find each other is in Automatic loading spinners in Angular.

The file system

With developers on different projects using a common system, something that comes up a lot is duplicated work. One example kept coming up: lots of different developers made their own date and time classes and abstractions. That was made worse by them defining these classes in one of the components where they were needed, so we ended up with files that defined many different things, a component and potentially some utility libraries.

The first way to tackle this is to require that every class is defined in its own file, named after the class. The second is a strict standard for the naming of those files and classes. The application was divided into a set of modules, and within each module every class had to start with the name of the module. Each module had a strict directory structure, too, which separated out ui, data, services, classes and so on at the top level:

calendar/
    classes/
        calendar-date.ts                      CalendarDate
        calendar-time-range.ts                CalendarTimeRange
    data/
        calendar-event.ts                     CalendarEvent
    services/
        calendar-time-zone.service.ts         CalendarTimeZoneService
    ui/
        calendar-date-picker.component.ts     CalendarDatePickerComponent
A module's directories, and the class in each file

This made it immediately apparent when something was in the wrong module. If it's about dates or times, it goes in the calendar module, and anywhere else its name gives it away. And before anyone writes a date class, they know exactly where to look to see if there's one already.

Pure components, almost

This is one I wasn't sure would work out, but it turned out fine, and beneficial: being strict about what components could actually contain.

It became clear that any of the lifecycle hooks, like ngOnChanges and ngOnDestroy, were an anti-pattern, and a sign that something was being done against the grain of the reactive system. ngOnChanges especially: if you're reacting to changes manually, it's a good sign that you're doing something the framework already has a better solution for. Here's a component working out its title whenever its input changes:

export class ProjectHeaderComponent implements OnChanges {
    @Input() project: ProjectData;
    title: string;

    ngOnChanges(changes: SimpleChanges) {
        if (changes.project) {
            this.title = this.project.name + ' (' + this.project.models.length + ' models)';
        }
    }
}
Reacting to a change by hand

The ideal pure component we landed on has a template that only uses the attributes passed into the component, or pure functions of those attributes. Generally these are a set of getters:

export class ProjectHeaderComponent {
    @Input() project: ProjectData;

    get title() {
        return this.project.name + ' (' + this.project.models.length + ' models)';
    }
}
The same title as a getter

Angular reads the getter whenever it checks the template, so the title is always right, and there's nothing to keep in step.

Some are pure functions that take arguments from where they're called in the template, and combine them with other pure functions and the component's attributes to produce a value. These can even be interactive, by using the loader pipe inside the template as the argument to the function:

<ui-text>{{ countDone(('/api/projects/' + projectId + '/tasks') | httpGet) }} tasks done</ui-text>
Passing loaded data to a pure function in the template
countDone(tasks: ProjectTask[] | null) {
    return (tasks || []).filter((task) => task.done).length;
}
The pure function

When the tasks arrive, or change, the pipe returns the new list and the count follows it. The component never knows anything was loaded.

State

We had a separate pattern for components that did genuinely need state. But because we grew an extensive UI library, most of the state was hidden away in there. The currently selected tab in a tabset, for example, belongs to <ui-tabset>, and the component using it never sees it:

<ui-tabset>
    <ui-tab title="Members">
        <project-members [projectId]="projectId"></project-members>
    </ui-tab>
    <ui-tab title="Settings">
        <project-settings [projectId]="projectId"></project-settings>
    </ui-tab>
</ui-tabset>
A tabset, which keeps track of its own selected tab

These applications were generally showing real-time data from the backend, so it was actually the communications module that did most of the state management. It provided the abstraction for setting values on the backend, and it kept track of all of the loader pipes in the application. So when a value was set, it could optimistically send the new value out of every loader pipe showing it, before it had even been set in the backend:

export class ProjectRenameComponent {
    @Input() projectId: number;

    constructor(private comms: CommsService) {}

    get nameUrl() {
        return '/api/projects/' + this.projectId + '/name';
    }

    rename(name: string) {
        this.comms.set(this.nameUrl, name);
    }
}
Setting a value through the communications module
<ui-text-input [value]="nameUrl | httpGet" (change)="rename($event)"></ui-text-input>
The rename component's template

Every httpGet pipe on that URL, anywhere in the application, shows the new name straight away, and no component had to hold on to it in between.

Standard libraries

We built up the standard libraries, for the user interface, for communications, and for things like date and time utilities, on demand, as we went along. The observation at the start was that these would grow much more slowly than the number of components in the application, and over time they slowed almost to a halt: it became very rare that we needed to add a new component to the library.

Where the code grows

The same goes for organising the file system: be very aware of, and plan for, which parts of it will grow rapidly and which will grow slowly over time. The number of modules grows rapidly as the features come in, because each feature goes in its own module within the application. But the number of standard components, the amount of CSS and the other shared things grow much more slowly:

calendar/     a standard library, grows slowly
comms/        a standard library, grows slowly
ui/           a standard library, grows slowly
members/      a feature
project/      a feature
...           a new module for each new feature
Where the application grows

You have to manage carefully where it grows. You don't want it growing everywhere all at once: you want it organised so that the parts that grow without limit as features are added are kept in order, and everything else grows in very specific ways.

Taking it further

The same idea goes further than titles and buttons. We also made abstractions for creating 3D graphics with three.js and SVG graphics through the template, and different types of interactive 2D and 3D maps. They're worth articles of their own: 3D components in Angular with three.js and SVG components in Angular.