May 29, 2021
I optimize for less cognitive load.
Unlike its counterparts — Elm, Ruby on Rails, re:frame of ClojureScript — React is a less-opinionated framework.
It leaves what to optimize for on the developer. I choose simplicity, as little cognitive load as possible.
React is a glue. Sits in the middle, between the user interface and the back-end, connecting multiple domains.
When React becomes a second nature it leaves room for acquiring expertise in the connected domains. It’s better to focus on the domains than getting stuck with a framework connecting them.
Responsive web design, screen typography, HTML and CSS is changing after a decade of stagnation; creating scalable design systems and component libraries is a challenge; event-driven architectures, reactive and functional programming are skills to learn.
On the other hand, optimizing for a goal helps decision making.
Coding is decision making. When the goal is clear, making decisions is easy.
It sounds vague but reducing cognitive load is possible, desirable at every phase in React development.
Creating a new component? Use a generator. No need to remember component structure and to write boilerplate code.
Importing a component? Use module path aliases. No need to remember project folder structure.
Defining function signature? Use a single composite prop. Then deal with the details later. No need to worry prematurely about data structures.
Writing logic? Use a general abstraction to tackle every problem in the same way.
Too much code inside a file? Split into atomic, standalone, small units responsible for a single aspect of the problem. Focus on a single aspect at once, iterate all aspects, then compose up the final result.
Sparing attention with little tricks add up. The less attention needed for non-creative code the more attention stays available for writing real code.
To make code look uniform I use generators.
Instead of manually creating project files, folders — and writing boilerplate code — I generate them.
For VSCode I use VSCode Folder Templates. In a project where team members use different editors we go for a command line generator like New Component.
The generator generates all files a component needs: the main component file, test and documentation files, style and data declarations, or anything else specific to the project.
Inside every file the generator generates boilerplate code. The final result is uniform, and is ready in seconds vs. minutes when code and structure duplicates by hand.
A simple component generator example:
// [FTName].tsx
import React from "react";
import { TComponent, component } from "@components";
import { edoStyle } from "@tokens";
import { defaultsDeep } from "lodash";
export interface T[FTName] extends TComponent<null> {}
export const [FTName | lowerCase]: T[FTName] = {
...component
};
export function [FTName](props: T[FTName]) {
const props2 = defaultsDeep({...props}, [FTName | lowerCase]);
const {className} = props2;
const style = edoStyle(className, "[FTName]");
return <p {...style}>[FTName]</p>;
}
// index.ts
export * from './[FTName]'
After generating a Button
component the result becomes:
// Button.tsx
import React from 'react'
import { TComponent, component } from '@components'
import { edoStyle } from '@tokens'
import { defaultsDeep } from 'lodash'
export interface TButton extends TComponent<null> {}
export const button: TButton = {
...component,
}
export function Button(props: TButton) {
const props2 = defaultsDeep({ ...props }, button)
const { className } = props2
const style = edoStyle(className, 'Button')
return <p {...style}>Button</p>
}
// index.ts
export * from './Button'
To make component code better understandable, to enable a common structure, I use logical units.
Every component is built on three sections:
// Button.tsx
/**
* Imports
*/
import React from 'react'
import { TComponent, component } from '@components'
import { edoStyle } from '@tokens'
import { defaultsDeep } from 'lodash'
/**
* Type and data definitions
*/
export interface TButton extends TComponent<null> {}
export const button: TButton = {
...component,
}
export const buttonQuery = `` // GraphQL
/**
* Component logic
*/
export function Button(props: TButton) {
const props2 = defaultsDeep({ ...props }, button)
const { className } = props2
const style = edoStyle(className, 'Button')
return <p {...style}>Button</p>
}
Absolute imports and module path aliases is a Typescript feature making project imports better comprehensible and easier to write.
In tsconfig.json
one can set up aliases, paths
pointing to common folders in the project.
{
"compilerOptions": {
"paths": {
"@data": ["data/"],
"@apps/*": ["apps/*"],
"@components": ["components"],
"@tokens": ["tokens/"]
}
}
}
Then in components, project-related imports
use these aliases vs. figuring out relative paths.
import { edoStyle } from '@tokens'
is easier to remember than import { edoStyle } from '../../design-system/tokens'
.
Programming is about transformation. The problem comes in, it gets solved, and the solution goes out.
The problem comes in as data. To describe it I use PropTypes, TypeScript and optionally, when the data comes from an API, GraphQL or JSON.
In any case I use type definitions.
Even when the data comes from the API. At first it seems definition duplication but the scope differs.
Type definitions assure the transformations (the functions) won’t break. Data definitions assure the front-end is in sync with the back-end.
When no data comes from the API, type definitions help to lay out a front-end API.
Yes, the front-end needs an API too. Otherwise how do you build a design system, or component library, with dozens of components and another dozen tokens with no back-end dictating a data structure?
In addition, type definitions make sure the component is minimal in scope, following the Single-responsibility Principle.
More than one type definition inside a component is a code smell. It means the component should split. It does more than a well-defined singular task.
// This is a code smell.
// `project`, `projects` should merge into a single interface.
export interface TProjectSlugPage extends TComponent<null> {
seo: TSeo;
project: TProjects;
error: TError;
}
export interface TProjectList {
projects: TProjects;
}
There are ways to define component props and associate default values to them.
A common approach is to destructure props in the function signature. Another approach is to destructure them in the function body.
// Destructuring in function signature
function Video({prop1, prop2}: TVideo) {...}
// Destructuring in function body
function Video(props: TVideo) {
const {prop1, prop2} = props
}
Associating default props comes with at least three different approaches.
// Associating default props in function signature
function Video({prop1, prop2}: TVideo = video) {...}
// Associating default props in function signature at destructuring
function Video({prop1: 'prop1', prop2: 'prop2'}: TVideo) {...}
// Associating default props in function body
function Video(props: TVideo) {
const {prop1, prop2} = merge(props, video)
}
Which approach is better? Which approach is complete? What makes an approach better than another?
A quick analysis shows where to destructure props, and assign default values to them depends on the shape of the props.
When props are a flat object and small in number, destructuring in function signature + assigning default values at destructuring, wins. In a capable editor ctrl+hover
over the function name displays good enough usage information.
Nested props require a special deep merging function to associate with the default props. This implies destructuring in the function body.
The developer experience with ctrl+hover
over the function name is less pleasant: the default values, helping to infer the prop type, are not shown.
A practice to follow should be:
// Props are flat and small in number.
// This approach is recommended.
//
// This approach gives the best developer experience:
// On `ctrl+hover` the type of the props can be inferred from their default value.
function Video({prop1 = 'prop1', prop2 = 'prop2'}: TVideo) {...}
// Props are flat but large in number.
// This approach is not recommended.
function Video({
prop1 = 'prop1',
prop2 = 'prop2',
prop3 = 'prop3',
...
...
prop10 = 'prop10'
}: TVideo) {...}
// Props are flat but large in number.
// This approach is recommended for better code readability.
//
// On `ctrl+hover` this approach doesn't give hints about the type of the props.
function Video(props: TVideo) {
const propsMerged = defaultsDeep({ ...props }, defaultProps)
const { prop1, prop2 } = propsMerged
}
// Props are nested.
// This approach is recommended (perhaps the only viable option).
function Video(props: TVideo) {
const propsMerged = defaultsDeep({ ...props }, defaultProps)
const { prop1, prop2 } = propsMerged
}
React plays the functional and reactive game.
It borrows often from Clojure/ClojureScript, a language built on a grand abstraction.
From Alex Miller’s screencast Clojure Enemy of the State
The idea is to transform initial data into a series of sequences and apply standard code upon the sequences.
This is how Clojure solves problems. Divides a problem into subproblems — small sequences — and applies standard problem solving techniques on each sequence.
The key is standard code / standard problem solving technique.
Clojure offers a vast standard library capable of manipulating all kinds of sequences. The task of a developer reduces to using the library vs. writing her own code. This way the solution is better: approved, used and reused by a community vs the brainchild of a single individual.
The Clojure way simplifies problem solving to a single task: find the best sequences. The rest is handled by previous wisdom.
I use the same approach in writing React component functions.
I start with the data (props), then create sequences from props, where I apply — ideally — standard functions.
export function Video(props: TVideo) {
/**
* Start with the data
*/
const props2 = defaultsDeep({ ...props }, video)
const { className, hosted, served } = props2
/**
* Chopped sequences: `hosted`, `served`
* Apply function on sequences
*/
const url = getVideoUrl(hosted, served)
if (!url) return null
/**
* Chopped sequence: `className`
* Apply function on sequence
*/
const style = edoStyle(className, 'Video')
/**
* When all the subproblems are solved ...
* ... return the result
*/
return <ReactPlayer url={url} {...style} />
}
There’s nothing extraordinary in the code above. It looks natural, and should look natural.
The advantage shows in time. A standard library grows along the projects offering reliability and faster development time for its users.
On another hand this technique offers uniform thinking across a team. It reduces the problem solving process to:
problem === data -> sequences -> solutions -> composition === solution
.
A key approach in functional programming is to create atomic sequences / subproblems and atomic solutions for them.
An atomic problem / solution is not further reducible. It is pure, it exists in a canonical state.
Practice shows systems compose up better when the underlying constructs, components are atomic / pure.
In the example above both defaultsDeep
, getVideoUrl
, edoStyle
and ReactPlayer
are atomic. They can’t be further reduced. And they return predictable results which compose up nicely.
If we look deeper, let’s say into the getVideoUrl
code, we should see pure / atomic code, again.
export function getVideoUrl(hosted, served): string[] | string | null {
if (!hosted && !served) return null
return hosted && hosted?.videoUrl
? getHostedUrl(hosted)
: getServedUrl(served)
}
And so on.
export function getHostedUrl(hosted): string | null {
return hosted?.videoUrl
? canPlayUrl(hosted?.videoUrl)
? hosted?.videoUrl
: null
: null
}
I tend to write pure / atomic functions. Not in the strict but the logical sense.
Functional programming is not a silver bullet. And it’s hard to learn when one is coming from object-oriented, imperative programming — as the majority of us do.
Understanding its principles and applying its style is often enough in a React environment. A short learning assures immediate better code.
I often find myself in front of a problem more complex than mapping and reducing data. In such cases I appeal to Ramda, a functional library for JavaScript.
Ramda is transparent to React. No matter if you write getVideoUrl
with Ramda or JavaScript. React can’t sense the difference.
The difference is in the cognitive load of the developer.
Writing slightly complex algorithms in the imperative way I found to take a larger toll on my attention and patience than writing the same algorithm with Ramda.
With Ramda I think more about the problem — in time, in depth — and write less code.
The end result feels compact. Even if I use only a small subset of the functional programming toolset — immutable data, curried functions, and compositions.
In pursuing simplicity — applying the ‘rules’ above — components decompose into smaller parts.
Oftentimes they decompose into multiple files. React calls this phenomenon co-location.
The component folder co-locates files specialized in single tasks: component code, component logic, style, tests, dev notes, specifications and anything else.
When the developer opens a folder it gets the big picture. What libraries, techniques this project is built on, how complex and complete the component is, where to start the work.
Then opens a file, reduces the focus, thus the cognitive load, to a single aspect of the problem. And works in peace.
# A sample component folder structure
Video.tsx
Video.test.tsx
Video.functions.ts
Video.functions.test.ts
Video.style.ts
Video.md
...
Reducing cognitive load should pay off.
Little things and tricks can spare attention — preserve it for solving the bulk of the problem.
Hopefully, this approach scales beyond individuals enabling faster and more concise development for teams.
After all, it is about simplifying structures and models.
Structure, model | Simplifying technique | Reduces cognitive load on / to |
---|---|---|
Folders | Generators | Boilerplate code |
Path aliases | Locating of components | |
Co-location | Aspects | |
Components | Logical sections | Locating parts of the component |
Data | Always have an API | Thinking about correctness |
Main function | Simple function signature | Thinking prematurely about non-significant aspects |
The Grand Abstraction | Thinking in sequences, don't worrying about the rest | |
Other functions | Pure, atomic, Single-responsibility Principle | Solve simple problems at once |
Programming | Functional, with Ramda | Composing up, declaring a solution |
To React with best practices. Written by @metamn.