This workshop contains a bunch of stuff that I think is important to understand to become a
good front end developer. Some of it - like how Array.prototype.reduce
works - is
fact. However, some of it - like when you should create a function - is my opinion, based on
experience. And some of it - like the accessibility stuff - contains some good rules of thumb,
but isn't really the complete story.
The point isn't to memorise everything here and apply it exactly as described. The point is that these are things that you should think about, and work out the best approach for your situation. Code written for production in an enterprise organisation demands very different standards than code written at home to try and learn a new topic. You need to work out what's going to be most helpful for you and your team.
That said, the opinions given here should give you a decent starting point. If you don't currently have an opinion on one of the topics we discuss here, feel free to just take mine and use it, while you work out your own. And if you do already have an opinion, I'd encourage you to think about why you have that opinion, and if there's anything else you might want to consider (even if your opinion agrees with mine)!
Clean code is code that is easy to understand and maintain. The term comes from the book Clean Code by Robert Martin . Writing clean code is about going beyond writing code that works, to writing coding that is great for the next person to work on. As Martin Fowler says
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.
Clean code
According to a 1979 study (quoted in
Steve McConnell's Code Complete)
understanding the original programmer's intent was the most difficult problem
.
Working out what the previous dev was trying to do is usually key to understanding bugs,
and is also important when adding new features, or updating existing ones.
Here's a couple of options, ranked by some important factors
| discoverability | |||
|---|---|---|---|
| finding the relevant information | |||
| confidence it's up-to-date | |||
| low | medium | high |
| KEY | |
|---|---|
| External documentation | |
| README | |
| Code comments | |
| Code itself |
As you can see from the table above, the closer to the code your documentation is, the easier it is to discover, the easier it is to find the relevant information, and the easier it is to trust that it's been kept up-to-date and relevant. The logical conclusion of this is that the code itself is the best way to signal your intent.
Of course, not all code is actually good at signalling intent, so we're going to look at some things you can do to help with this.
Don't use acronyms or abbreviations. It might be obvious to you what you mean, but will it be obvious to the next developer? What if they're a junior? Or someone who is new to the domain?
Where I work, we have an ID field called MIRN
. I've been there a year and a
half and I still have no idea what this stands for.
But even in cases where someone could work out what the abbreviation stood for, you're making them use extra brain-power. And they might need that brown-power for something that actually needs to be difficult. Just use the whole word - your IDE can autocomplete it for your if the typing gets too much.
data
, items
,
x
don't convey any meaning. Pick something descriptive. (I usually make an
exception to this rule for loop indices - I think i
is commonly-enough understood
as a loop index to cause no confusion. That said, I rarely use loop indices, as we'll see later on.)
message
and a notification
should
be different things. Using synonyms to refer to the same object just adds ambiguity to your code. It's
not clear to a new developer whether those things refer to the same thing, or which name they should use
for the new thing that they're creating.
getStoredBankDetails() {
if (this.activePaymentOption === PaymentOptions.BANK) {
this.options.push(this.storedPayments.bankAccount);
}
}
This function doesn't do what it says. I would expect a function named getStoredBankDetails
,
but what it actually does is add the bank account details to a list of options. It would be better named
addBankDetailsToOptions
hasAccountChanged(newAccount) {
const currentAccount = this.activeAccount;
const hasChanged = (
currentAccount.accountName !== newAccount.accountName ||
currentAccount.accountNumber !== newAccount.accountNumber ||
currentAccount.bsb !== newAccount.bsb
);
this.isDirectDebitChanged = hasChanged;
return this.isDirectDebitChanged;
}
This function does what it says on the tin. But it also has an unexpected side effect - it updates the
value of isDirectDebitChanged
. There are a couple of ways we could handle this better,
depending on what the original intent was. We could have the calling code set isDirectDebitChanged
itself.
this.isDirectDebitChanged = this.hasAccountChanged(account)
Or, we could change the name of the function to updateDirectDebitChanged
.
Don't reuse variables, even if the two uses seem related. Discovering that a variable
suddenly refers to something else is surprising, and we already agreed not to be surprising. For example, if you have
a function named element()
that returns an element, don't be tempted to do something like
var element = element('div');
You've turned a function into an object and that's potentially going to confuse the hell out of someone later.
ENABLED
,
then the opposite is DISABLED
, not OFF
, or OUT
or anthing else.
Similarly, if you have function called login()
, then its partner should be logout()
,
not logOut()
, or signout()
, or anything else.
The signal-to-noise ratio refers to how much of your code conveys actual meaning to the reader, compared to the amount that is just boilerplate, or repeating something that's been conveyed elsewhere. Meaningless code is all that stuff that you just skip over when you're reading the code, because it's not helpful.
We can improve the signal-to-noise ratio by either increasing the signal (adding meaning), or reducing the noise (removing the useless stuff). (Or, ideally, both!)
Often, you can increase the signal-to-noise ratio by reducing the amount of code that you write. Code like
let isParentNode;
if (hasChildren(node)) {
isParentNode = true;
} else {
isParentNode = false;
}
can be greatly simplified down to
const isParentNode = hasChildren(node)
It's not always quite as obvious as this, but it's worth keeping an eye out for this pattern. Often, your IDE or linter can spot (and autofix) things like this for you.
Another way you can increase the signal to noise ratio, is by moving the "noise" away from the signal. For example, imagine we had a function like the following, which checks if a file is valid, and then posts it to the server if it is:
const match = file.name.match(/\.[a-z]+$/);
let extension;
if (match && match.length > 0) {
extension = match[0];
}
let valid;
if (extension === 'jpg' || extension === 'jpeg' || extension === 'png' || extension === 'svg') {
valid = true;
}
if (file.size > MAX_SIZE) {
valid = false;
}
if (valid) {
const formData = new FormData();
formData.append('files[]', file);
fetch(URL, {
method: 'POST',
body: formData,
})
}
You can probably make out what's going on, but it's not... great. We can improve it by moving the "noise" of the implementation details away from the main decision flow. Our main flow would contain only
// ...
if (isFileValid(file.name, file.size)) {
sendFile(file);
}
// ...
And then we could put the implementation details later on, possibly even in another file or module.
function isFileValid(name, size) {
if (fileSize > MAX_SIZE) {
return false;
}
const match = filename.match(/\.[a-z]+$/);
let extension;
if (match && match.length > 0) {
extension = match[0];
}
const validFileTypes = ['jpg', 'jpeg', 'png', 'svg'];
return validFileTypes.includes(extension);
}
function sendFile(file) {
const formData = new FormData();
formData.append('files[]', file);
fetch(URL, {
method: 'POST',
body: formData,
})
}
Here, the function names act like a kind of outline of what's going on: if the file is valid, then send it. If we need to know the details about how we determine if a file is valid, or how we send a file, then we can look inside the functions. But generally, when you're reading code, you're looking for a high level overview, then dipping into a deeper level when you get to the bit that interests you. Structuring your code with functions that hide implementation details like this makes that easier to do.
We've also added meaning in the file validation code by replacing the lengthy if statement with
const validFileTypes = ['jpg', 'jpeg', 'png', 'svg'];
return validFileTypes.includes(extension);
Adding the validFileTypes
variable adds extra meaning to our code, and the list check is
much shorter, and easier to read, than the if statement. Plus, it's now easier to add or remove valid
file types.
There are also a few techniques we can use to reduce the noise in our code. For example, heavy indenting adds a lot of noise and makes your code harder to understand.
This class is constructing the view for a component which offers a special offer to selected customers. It's not written for any framework - we're just going to create an object that contains the details that are going to be rendered somewhere else. We're going to show the customer the details of the offer, or an error message if they are eligible for an offer, but we couldn't fetch the details for some reason.
export class SpecialOfferComponent {
//...
render(view) {
//... code to render the view in HTML
}
createView(account) {
const view = {};
if (account) {
view.accountType = account.type;
const offerDetails = account.offerDetails;
if (offerDetails) {
const errorCode = offerDetails.error && offerDetails.error.code;
view.title = null;
view.errorText = null;
const offerDetailsExist = offerDetails && !errorCode;
if (offerDetailsExist) {
view.offerAccepted = offerDetails.offerAccepted;
view.title = view.offerAccepted ? TITLE_OFFER_ACCEPTED : TITLE_OFFER;
view.amountSaved = offerDetails.potentialSavings;
view.offerExpiry = offerDetails.expiryDate;
} else {
if (this.isBillingExceptionError(errorCode)) {
view.errorText = TEXT_BILLING_EXCEPTION;
} else if (this.isRecoverableError(errorCode)) {
view.errorText = TEXT_RECOVERABLE_ERROR;
} else if (this.isFatalError(errorCode)) {
view.errorText = TEXT_FATAL_ERROR;
}
}
}
}
return view;
}
isFatalError(errorCode) {
return (
this.errorCode === ErrorCode.FATAL_ERROR_CODE ||
(!!errorCode && !this.isRecoverableError(errorCode) && !this.isBillingExceptionError(errorCode))
);
}
isRecoverableError() {
return this.errorCode === ErrorCode.RECOVERABLE_ERROR_CODE;
}
isBillingExceptionError() {
return this.errorCode === ErrorCode.BILLING_EXCEPTION_CODE;
}
}
While this code isn't super complicated, there is still a lot of noise - it's heavily indented, making it
harder to trace a path through the code, and the else
block at the very end contains a lot
of repeated code. Let's see if we can clean it up a bit.
If we start at the top of the createView
function, we can see that basically all the
functionality is wrapped up in an if block that checks if the account exists. So, straight away we
can get rid of one whole level of indenting, by moving this to a guard at the top of the function, and
returning early if the guard fails. So instead of
createView(account) {
const view = {};
if (account) {
// all the functionality...
}
}
we can do
createView(account) {
const view = {};
if (!account) {
return view;
}
// all the functionality
}
A little further down, we can see a similiar situation - a large block of code is wrapped up in the check for
offerDetails
. Again, we can remove a level of indenting, by returning early from the function.
So instead of
createView(account) {
const view = {};
if (!account) {
return view;
}
view.accountType = account.type;
const offerDetails = account.offerDetails;
if (offerDetails) {
// remaining functionality...
}
}
we can do
createView(account) {
const view = {};
if (!account) {
return view;
}
view.accountType = account.type;
const offerDetails = this.account.offerDetails;
if (!offerDetails) {
return view;
}
// remaining functionality...
}
Already our code is looking a lot flatter.
createView(account) {
const view = {};
if (!account) {
return view;
}
view.accountType = account.type;
const offerDetails = account.offerDetails;
if (!offerDetails) {
return view;
}
const errorCode = offerDetails.error && offerDetails.error.code;
view.title = null;
view.errorText = null;
const offerDetailsExist = offerDetails && !errorCode;
if (offerDetailsExist) {
view.offerAccepted = offerDetails.offerAccepted;
view.title = view.offerAccepted ? TITLE_OFFER_ACCEPTED : TITLE_OFFER;
view.amountSaved = offerDetails.potentialSavings;
view.offerExpiry = offerDetails.expiryDate;
} else {
if (this.isBillingExceptionError(errorCode)) {
view.errorText = TEXT_BILLING_EXCEPTION;
} else if (this.isRecoverableError(errorCode)) {
view.errorText = TEXT_RECOVERABLE_ERROR;
} else if (this.isFatalError(errorCode)) {
view.errorText = TEXT_FATAL_ERROR;
}
}
return view;
}
Now, the next if
statement is actually taking alternate paths, so we can't
get rid of it by returning early. What we can do, however, is use a trick we learnt earlier -
extracting logic out into a function. We can extract the actual logic of updating the view details
to two separate functions - one for the normal view, and one for the error view, and replace the
if
statement with a ternary operator (because it's a bit shorter. You could equally leave the
if
in, if you felt it improved readability).
createView(account) {
const view = {};
if (!account) {
return view;
}
view.accountType = account.type;
const offerDetails = account.offerDetails;
if (!offerDetails) {
return view;
}
const errorCode = offerDetails.error && offerDetails.error.code;
view.title = null;
view.errorText = null;
const offerDetailsExist = offerDetails && !errorCode;
offerDetailsExist ? extendViewWithOfferDetails(view, offerDetails) : extendViewWithError(view, errorCode);
return view;
}
The extendViewWithOfferDetails()
function isn't that interesting - we can just move the
existing logic into the new function.
extendViewWithOfferDetails(view, offerDetails) {
view.offerAccepted = offerDetails.offerAccepted;
view.title = view.offerAccepted ? TITLE_OFFER_ACCEPTED : TITLE_OFFER;
view.amountSaved = offerDetails.potentialSavings;
view.offerExpiry = offerDetails.expiryDate;
return view;
}
The extendViewWithError()
function, on the other hand, could use a bit of
work. If we were to just lift and shift the contents of the else
statement into the
new function, we'd get something like
extendViewWithError(view, errorCode) {
if (this.isBillingExceptionError(errorCode)) {
view.errorText = TEXT_BILLING_EXCEPTION;
} else if (this.isRecoverableError(errorCode)) {
view.errorText = TEXT_RECOVERABLE_ERROR;
} else if (this.isFatalError(errorCode)) {
view.errorText = TEXT_FATAL_ERROR;
}
}
isFatalError(errorCode) {
return (
this.errorCode === ErrorCode.FATAL_ERROR_CODE ||
(!!errorCode && !this.isRecoverableError(errorCode) && !this.isBillingExceptionError(errorCode))
);
}
isRecoverableError() {
return this.errorCode === ErrorCode.RECOVERABLE_ERROR_CODE;
}
isBillingExceptionError() {
return this.errorCode === ErrorCode.BILLING_EXCEPTION_CODE;
}
Let's start by looking at that isFatalErrorCode
function. It's saying that we should treat the
error code as a fatal error code if
So, any error that isn't a recoverable error, or a billing exception error should be treated as a fatal error.
It's the default value! We don't need to do a check for that - we can just use a plain else
statement.
extendViewWithError(view, errorCode) {
if (!errorCode) {
return view;
}
if (this.isBillingExceptionError(errorCode)) {
view.errorText = TEXT_BILLING_EXCEPTION;
} else if (this.isRecoverableError(errorCode)) {
view.errorText = TEXT_RECOVERABLE_ERROR;
} else {
view.errorText = TEXT_FATAL_ERROR;
}
}
isRecoverableError() {
return this.errorCode === ErrorCode.RECOVERABLE_ERROR_CODE;
}
isBillingExceptionError() {
return this.errorCode === ErrorCode.BILLING_EXCEPTION_CODE;
}
That just leaves one more if
statement! If we look carefully at this if
we can
see that it's following a pattern - each branch is setting the errorText
, based on the value
of the errorCode
. This is exactly the kind of pattern that can be handled with a switch
statement.
extendViewWithError(view, errorCode) {
if (!errorCode) {
return view;
}
switch (errorCode) {
case ErrorCode.BILLING_EXCEPTION_CODE:
view.errorText = TEXT_BILLING_EXCEPTION;
break;
case ErrorCode.RECOVERABLE_ERROR_CODE:
view.errorText = TEXT_RECOVERABLE_ERROR;
break;
default:
view.errorText = TEXT_FATAL_ERROR;
}
return view;
}
This is a bit better, but JavaScript gives us an option that is one better again - we can store all the values in an object literal (or dictionary) and just look them up!
const ERROR_TEXTS = {
[ErrorCode.BILLING_EXCEPTION_CODE]: TEXT_BILLING_EXCEPTION,
[ErrorCode.RECOVERABLE_ERROR_CODE]: TEXT_RECOVERABLE_ERROR
};
extendViewWithError(view, errorCode) {
if (!errorCode) {
return view;
}
view.errorText = ERROR_TEXTS[errorCode] || TEXT_FATAL_ERROR;
return view;
}
This code says look up the text for the given errorCode
in the ERROR_TEXTS
dictionay.
If you don't find it, use TEXT_FATAL_ERROR
. This code is shorter and easier to read than than the
if
statement. It also makes it very easy to add a new error code - you just add it to the dictionary,
and it will work. This pattern shows up a lot in JavaScript code - I recommend keeping an eye out for it.
Ok, so now how is our createView
function looking?
const ERROR_TEXTS = {
[ErrorCode.BILLING_EXCEPTION_CODE]: TEXT_BILLING_EXCEPTION,
[ErrorCode.RECOVERABLE_ERROR_CODE]: TEXT_RECOVERABLE_ERROR
};
createView(account) {
const view = {};
if (!account) {
return view;
}
view.accountType = account.type;
const offerDetails = account.offerDetails;
if (!offerDetails) {
return view;
}
const errorCode = offerDetails.error && offerDetails.error.code;
view.title = null;
view.errorText = null;
const offerDetailsExist = offerDetails && !errorCode;
offerDetailsExist ? extendViewWithOfferDetails(view, offerDetails) : extendViewWithError(view, errorCode);
return view;
}
extendViewWithOfferDetails(view, offerDetails) {
view.offerAccepted = offerDetails.offerAccepted;
view.title = view.offerAccepted ? TITLE_OFFER_ACCEPTED : TITLE_OFFER;
view.amountSaved = offerDetails.potentialSavings;
view.offerExpiry = offerDetails.expiryDate;
return view;
}
extendViewWithError(view, errorCode) {
if (!errorCode) {
return view;
}
view.errorText = ERROR_TEXTS[errorCode] || TEXT_FATAL_ERROR;
return view;
}
This is looking pretty good! It's easier to follow a path through the main function, due to the lack of indenting. We've increased the signal-to-noise ratio by extracting code out to functions with meaningful names, and removing repetitive code. And we've made it easy to update by extracting configuration out into a dictionary. At this point, there's just one more thing I would do, which is to remove these two lines:
view.title = null;
view.errorText = null;
There is no reason to set either of these values to null
explicitly. If we need to use them, they'll be set to
some value. Otherwise, they can just remain undefined
. As a general rule of thumb, you should never initialise
a variable to null
or undefined
. It just doesn't add any value. You can set your variables to
null
or undefined
if they've already been set to a value and you want to "unset" them. You can
even have a whole holy war about whether you should use null
or undefined
. You just don't need
to set them to that to start with.
Ok, so the final version of our class.
const ERROR_TEXTS = {
[ErrorCode.BILLING_EXCEPTION_CODE]: TEXT_BILLING_EXCEPTION,
[ErrorCode.RECOVERABLE_ERROR_CODE]: TEXT_RECOVERABLE_ERROR
};
export class SpecialOfferComponent {
// ...
render(view) {
//... code to render the view in HTML
}
createView(account) {
const view = {};
if (!account) {
return view;
}
view.accountType = account.type;
const offerDetails = account.offerDetails;
if (!offerDetails) {
return view;
}
const errorCode = offerDetails.error && offerDetails.error.code;
const offerDetailsExist = offerDetails && !errorCode;
offerDetailsExist ? extendViewWithOfferDetails(view, offerDetails) : extendViewWithError(view, errorCode);
return view;
}
extendViewWithOfferDetails(view, offerDetails) {
view.offerAccepted = offerDetails.offerAccepted;
view.title = view.offerAccepted ? TITLE_OFFER_ACCEPTED : TITLE_OFFER;
view.amountSaved = offerDetails.potentialSavings;
view.offerExpiry = offerDetails.expiryDate;
return view;
}
extendViewWithError(view, errorCode) {
view.errorText = ERROR_TEXTS[errorCode] || TEXT_FATAL_ERROR;
return view;
}
}
Compared with the original.
export class SpecialOfferComponent {
//...
render(view) {
//... code to render the view in HTML
}
createView(account) {
const view = {};
if (account) {
view.accountType = account.type;
const offerDetails = account.offerDetails;
if (offerDetails) {
const errorCode = offerDetails.error && offerDetails.error.code;
view.title = null;
view.errorText = null;
const offerDetailsExist = offerDetails && !errorCode;
if (offerDetailsExist) {
view.offerAccepted = offerDetails.offerAccepted;
view.title = view.offerAccepted ? TITLE_OFFER_ACCEPTED : TITLE_OFFER;
view.amountSaved = offerDetails.potentialSavings;
view.offerExpiry = offerDetails.expiryDate;
} else {
if (this.isBillingExceptionError(errorCode)) {
view.errorText = TEXT_BILLING_EXCEPTION;
} else if (this.isRecoverableError(errorCode)) {
view.errorText = TEXT_RECOVERABLE_ERROR;
} else if (this.isFatalError(errorCode)) {
view.errorText = TEXT_FATAL_ERROR;
}
}
}
}
return view;
}
isFatalError(errorCode) {
return (
this.errorCode === ErrorCode.FATAL_ERROR_CODE ||
(!!errorCode && !this.isRecoverableError(errorCode) && !this.isBillingExceptionError(errorCode))
);
}
isRecoverableError() {
return this.errorCode === ErrorCode.RECOVERABLE_ERROR_CODE;
}
isBillingExceptionError() {
return this.errorCode === ErrorCode.BILLING_EXCEPTION_CODE;
}
}
The new version isn't the only way we could have cleaned up this code. It's not even necessarily the best way. But, hopefully you agree that the new version is much easier to read, and it's going to be easier to modify, if we ever need to. It's also going to be easier to track bugs through, as there aren't really any branches - just dead ends.
Using JavaScript idioms is another way to potentially reduce noise in code. For example, something like
let name = user.getName();
if (name === undefined) {
name = 'hey you';
}
can be simplified to
const name = user.getName() || 'hey you';
(If you're not familiar with the ||
shortcut, in JavaScript: ||
returns the first truthy value,
or the last value, if none of them are truthy. In this case, if user.getName()
returns a value, then we'll get that
value. Otherwise, we'll get 'hey you'.)
A couple of things to keep in mind when using idioms like this though:
Here are a few JavaScript idioms you should be familiar with:
||
to set defaults (like we did above)
Using &&
for null-checking. &&
will return the first falsey value, or the last value if all values are truthy.
So, we can do null-checking like
const name = user && user.name && user.name.firstName;
If either user
or user.name
are null or undefined, then name
will be null or undefined. Otherwise,
it will have the value of user.name.firstName
!!
. The negation operator (!
) converts a value to boolean. So
!someTruthyValue
equals false
. !!
sets it back to the boolean equivalent of its initial value.
So !!someTruthyValue
equals true
.
+
.
const age = +document.getElementById('age').value;
Converting a non-numeric value will result in NaN
, so make sure your code includes the appropriate checks.
Creating a copy via JSON. You can create a deep copy of an object using JSON.parse()
and JSON.stringify()
.
const copy = JSON.parse(JSON.stringify(objectToCopy))
In fact, it's often the fastest way to create a deep copy. However, there are a few things you should be aware of before using it.
Firstly, it will only work if your object contains nothing but strings, booleans, dates, and numbers. Functions, regexes and other
special values will either be lost, or magically turned into a different value. A regex, for example, will end up as an empty
object, and Nan
gets converted to null
. So you need to be very sure about what's in your object before
copying it this way.
Secondly, because JavaScript doesn't natively support deep copies (without resorting to hacks), some people consider deep copies
to be a code smell - an indication that there are other problems with your code. When you find yourself trying to perform a
deep copy, it's probably a good idea to ask yourself if that's really what you want to do, if there's some other way you could
structure the code to avoid doing it. If you actually want to create a shallow copy, there are a couple of ways to do that
- you can use Object.assign()
, or array or object destructuring. (We will go into the details of array and object
destructuring later on.)
const objectCopy1 = Object.assign({}, objectToCopy);
const objectCopy2 = {...objectToCopy};
const arrayCopy1 = Object.assign([], arrayToCopy);
const arrayCopy2 = [...arrayToCopy];
So, while I definitely recommend that you make sure you understand how the JSON.parse(JSON.stringify())
trick
works - because you're definitely going to see it in other people's code - I would think twice before using it yourself.
Perhaps the most effective way you can reduce noise in your code, though, is by DRYing it out. If you've not heard of DRY before, it stands for "Don't Repeat Yourself". At its most fundamental, this means that you shouldn't implement the same logic twice in your code. Instead, factor the repeated logic out into a function, class, or module.
There are several reasons why you want to avoid repeated logic in your code.
For example, imagine we're dealing with credit card payments. We want to be able to display credit cards that a user has
previously stored with us. We want to show the type of the card, but we don't store that, so we need to calculate it from
the card number each time. This logic turns out to be slightly complicated, so let's use the card-validator
NPM package. The code looks something like this.
import { cardValidator } from 'card-validator';
function getCardType(cardNumber) {
return cardValidator.number(cardNumber).card.niceType;
}
The card-validator
library returns a niceType
of "American Express" for American Express
cards. However, our designers would prefer that we displayed American Express cards as "AMEX". (Based on a true story.)
We can add a little conversion into our function. (The credit-card-type
package provides the type strings used
by card-validator
.)
import { types } from 'credit-card-type';
import { cardValidator } from 'card-validator';
function getCardTypeForDisplay(cardNumber) {
const card = cardValidator.number(cardNumber).card;
return card.type === types.AMERICAN_EXPRESS ? 'AMEX' : card.niceType;
}
Great. But we also have a form where the user enters their credit card details. On this form, we want to
display the card type, as the user types the number. And we also want to display "AMEX" instead of "American Express".
(The card-validator
library can determine the card type based on a partial number as the user types.)
We also want to display an error if the user enters a card type that we don't support.
import { types } from 'credit-card-type';
import { cardValiator } from 'card-validator';
const allowedTypes = [types.AMERICAN_EXPRESS, types.MASTERCARD, types.VISA];
function updateCardType(cardNumber) {
const card = cardValidator.number(cardNumber).card;
if (!allowedTypes.includes(card.type)) {
throw new Error('Card type not allowed');
}
return card.type === types.AMERICAN_EXPRESS ? 'AMEX' : card.niceType;
}
Ok. Now what happens if the designers decide to change "AMEX" to "Amex"? We have to change it in two places. Two might not seem like many, but there could easily be way more than two places that we display credit card types. And what if we forget one of them? I mean, it's not a huge deal - our labels will just be a little different. Eventually someone will notice and fix it. But how will they know how to fix it? Are the labels supposed to have different values? If not, then which label is the correct one?
It's much better to keep this kind of information in a single, centralised location.
import { types } from 'credit-card-type';
import { cardValidator } from 'card-validator';
export function getCardType(cardNumber) {
const card = cardValidator.number(cardNumber).card;
const cardType = {
type: card.type,
niceType: card.niceType
};
if (card.type === types.AMERICAN_EXPRESS) {
cardType.niceType = 'AMEX';
}
return cardType;
}
We can then use this new function everywhere that we need to display card types.
import { getCardType } from '../card-utils';
function getCardTypeForDisplay(cardNumber) {
return getCardType(cardNumber).niceType;
}
import { getCardType } from '../card-utils';
const allowedTypes = [types.AMERICAN_EXPRESS, types.MASTERCARD, types.VISA];
function updateCardType(cardNumber) {
const cardType = getCardType(cardNumber);
if (!allowedTypes.includes(cardType.type)) {
throw new Error('Card type not allowed');
}
return cardType.prettyType;
}
Yes, this logic is a bit longer to implement. However, it's much shorter to use. And, it hides the details of the library used to fetch the name. We could easily swap out the library if something newer and shinier came along, without needing to change it in multiple places.
This is the kind of trade-off you need to think about when DRYing out your code. Is the increase in implementation effort going to be offset by the ease of use? Is hiding implementation details going to make life easier for someone in the future. Often, the answer will be yes, but that's not always going to be the case.
Dan Abramov describes a time when he DRYed out some code and it didn't go so well on his blog. It's important to remember that when DRYing code, we really want to remove repeated logic, not just code that looks similar. And it's far more important to have code that is easy to understand and maintain, than to have code that never repeats a line.
Most studies show that humans can really only hold 4-5 things in our working memory at a time (Working memory capacity on Wikipedia). This means that we can really only keep track of that many things easily. However, we also have the ability to "chunk" data - that is to group a number of pieces of data together, and think of them as a single piece. For example, most people would struggle a bit remembering a list of eight single-digit numbers. But remembering two 4-digit years is fairly trivial.
How can this help us write code? We break our code up into chunks - where a chunk is a function, a class, or a module. Each chunk should have a single, unifying purpose - something you can describe in two or three words. Chunks can be made of smaller chunks, but each chunk should consist of no more than 4-5 smaller units.
The idea that each class should do only one thing comes from object-oriented programming, and is called the Single Responsiblity Principle. More formally stated, the Single Responsibility Principle states that a class should only have one reason to change. I don't find that definition particularly helpful - but classes (and functions) that do more than one thing do tend to be more difficult to remember how to use.
Take, for example, the built-in JavaScript function Array.prototype.splice
.
let arrDeletedItems = array.splice(start[, deleteCount[, item1[, item2[, ...]]]])
It takes a start index and a number of things to be deleted. It also takes an optional list of things to be added to the array. (Except it's not a list, you just add the other things to the end of the parameter list.) It deletes the number of things to be deleted, starting from the start index, adds in all the things from the end of the parameter list, and returns the list of things that were deleted.
This function does two things - it deletes items, and it adds them. And because it does two things, it has a name that isn't very
descriptive. So, it's easily confused with the similarly named Array.prototype.slice
. It's also the only way to delete
items from an array. Wouldn't it be easier if we had a function named removeItems
?
const updatedArray = array.removeItems(start, deleteCount)
We could have another function that added items in, if we really needed it.
const updatedArray = array.addItems(start, itemsToAdd)
Unfortunately, I can't really give you a hard and fast rule for what constitutes "one thing", but there are some red flags that you can keep an eye out for.
splice
)?
Have you ended up with a very generic class name, like System
or Manager
? Does your function name
have a conjunction in it (like 'and', 'or', 'if', or 'when')?
Parameters are something else that we need to keep track of when understanding a function. So it probably won't suprise you when I say the fewer parameters the better. The ideal number of parameters for a function is zero. A function that takes zero parameters and always produces the same results is very easy to understand! Of course, writing all our functions with no parameters kind of limits what we can do, so functions with one or two parameters are ok too. Three, at a pinch. Using more than three parameters greatly increases the cognitive load of using your function though. For a start, it's harder to remember what all the parameters do!
function createUser(firstName, lastName, emailAddress, phoneNumber, githubUser, twitterHandle) {
...
}
If you do find yourself needing to pass a lot of parameters into a function though, then don't forget that we can use chunking to help. Can the parameters be grouped together into a single cohesive object? Well, that only counts as one parameter!
function createUser(name, contactDetails) {
...
}
One last note on the topic of clean code: keeping your code clean is going to require frequent refactoring. And reliable refactoring requires good tests. Good tests mean tests that test the intent of your code, not the implementation details.
it('sets the valid flag when the phone number is changed to an invalid value', () => {
const INVALID_PHONE_NUMBER = '12345';
spyOn(component, 'validatePhoneNumber').and.returnValue(false);
spyOn(component, 'setFormValidity');
component.onChange(INVALID_PHONE_NUMBER);
expect(component.validatePhoneNumber).toHaveBeenCalled();
expect(component.setFormValidity).toHaveBeenCalledWith(false);
});
This test is testing that the component checks the validity of a phone number, when it changes. It tests that the
component calls its own validatePhoneNumber
function, then calls the setFormValidity
function
with a value of false. This is not really the ideal way to test this.
validatePhoneNumber
method,
our test will fail, even if the component continues to work correctly. Tests should really only text public APIs - the things
that are going to break stuff if they change.
validatePhoneNumber
is the one that should be passed into
setFormValidity
. Our test isn't testing this, so we'd need to write another test. Plus, we could potentially end up
wasting time testing scenarios that can't acutally happen!
valid
flag on the form gets set to false
when the input is invalid.
A better test for this functionality would be
it('sets the valid flag when the phone number is changed to an invalid value', () => {
const INVALID_PHONE_NUMBER = '12345';
component.onChange(INVALID_PHONE_NUMBER);
expect(component.form.valid).toBe(false);
});
This test tests the outcome or our action. It tells us everything we need to know. It's much shorter. And it's not tied to a specific internal implementation of our component.
One thing that can help with writing more helpful tests is using Test Driven Development (TDD). If you've not heard of TDD, the idea is that you write tests based on your acceptance criteria, and then write the code to pass the tests. If you've never tried TDD before, I highly recommend giving it a try, at least for a little while. I personally find it very helpful when fixing bugs, and writing "utility" code, like state management, or transformations. I find it less helpful for writing component code.
"Uncle" Bob Martin is a huge proponent of TDD. You can find a bunch of articles about it on his blog, including this one on designing tests.
When it comes time to actually do the refactoring, smaller is better. Refactor a small piece of code, and check that it still works. Then do another small piece. Once you've done a few small pieces you can combine them into a larger refactor. Ideally though, you should be able to drop your refactoring work at just about any point, and still have functioning code.
And finally, make deliberate decisions about what code you're going to refactor. Generally speaking, only refactor code that you're actually touching. It might be tempting to re-write that 1000-line component written in archaic JavaScript with inconsistent naming everywhere, but if it's actually working ok, then you're more likely to just introduce bugs. If you find yourself needing to make changes to it, then feel free to factor out parts of it, but don't try and do the whole thing. Our goal is to create code that is easy to reason about and change, but not at the cost of breaking existing functionality.
JavaScript underwent a fairly serious update in 2015, which introduced a lot of new features into the language. Since then, it has had incremental additions every year, to ensure the language continues to update and evolve without the significant overhead of the 2015 update. Some of these new features add brand new functionality to the language, but many of them are actually intended to make it easier to follow the clean code principles we just discussed - making our code easier to reason about by making it less surprising, and increasing the signal-to-noise ratio by removing boilerplate and introducing standard methods to do things.
Today, we're going to have a look at a few of these new features, and how how they can help us write cleaner code.
ES6 (the 2015 version of JavaScript) introduced two new ways to declare variables, bringing the total to three: let
,
const
, and var
. Each declaration type has different properties.
| Mutable | Scoping | |
|---|---|---|
var | ✔ | function |
let | ✔ | block |
const | ❌ | block |
Both var
and let
allow you to change what your variable refers to after declaration.
var favouriteNumber = 4;
favouriteNumber = 6; // perfectly OK.
let count = 0;
count++; // also completely alright.
const
, on the other hand, doesn't.
const quest = "seek the Holy Grail";
quest = "run away"; // TypeError: invalid assignment to const 'quest'
It's important to realise that it's the variable reference that's immutable though, not the variable itself. If your variable refers to an object, you can't change it to refer to another object, but you can change the properties of the object.
const lancelot = {
favouriteColour: 'blue'
};
const galahad = {
favouriteColour: 'blue'
}
galahad = lancelot; // TypeError: invalid assignment to const 'galahad'
galahad.favouriteColour = 'yellow'; // Quite alright
So when should you use const
? I would suggest using it everywhere that you can. If you default to declaring
your variables const
, then you're never going to accidentally forget to do it when you need to. If, on the other
hand, you declare a variable as const
when it needs to be mutable, then your IDE will tell you straight away, so
you're not going to introduce any weird bugs.
There are plenty of people who would disagree with this assessment though. There is an argument that declaring everything as
const
dilutes its meaningfulness. Instead you should declare everything as let
, unless you specifically
need const
. And there are people who don't care either way - just pick one and stick with it. Dan Abramov summarises
the arguments for both sides in a blog post on the topic, if you're interested.
When it comes down to it, it's unlikely that it's going to make a huge difference which you choose, so just pick one and go with it.
The other factor we have to consider when deciding how to declare variables is whether we want function scoping, or block scoping. Fortunately, this one has an easy answer. You want block scoping. Function scoping is weird and scary.
To put it bluntly, don't use var
. Like, ever.
What do I mean when I say function scoping is weird and scary? It means your variable can keep on living outside of the curly braces where it's declared. So you can do stuff like
for (var i = 0; i < 10; i++) {
try {
processItem(items[i]);
} catch (e) {
break;
}
}
console.log(`Successfully processed ${i} items`);
or
var hour = (new Date()).getHours();
if (hour < 13) {
var greeting = 'Good morning!';
} else {
var greeting = 'Good afternoon!';
}
console.log(greeting);
In the first example, we're declaring the variable i
inside the for
loop, then accessing it outside
the loop. In the second example, we're declaring the variable greeting
in two different places.
This might seem like a handy feature. (I know I find the second one tempting when I'm building strings conditionally!) However, this isn't how variables work in most languages, so it makes our code a little more suprising. More importantly though, it's confusing because variables appear to be being used in higher scopes than the one they were declared in. Especially in the second example, you have to read what's going on inside the conditional blocks to be able to understand what's going on outside, which is the inverse of how clean code should work.
let
and const
remove the possibility of doing this.
for (let i = 0; i < 10; i++) {
try {
processItem(items[i]);
} catch (e) {
break;
}
}
console.log(`Successfully processed ${i} items`); // Uncaught ReferenceError: i is not defined
Instead, we'd have to do something like
let i;
for (i = 0; i < 10; i++) {
try {
processItem(items[i]);
} catch (e) {
break;
}
}
console.log(`Successfully processed ${i} items`);
In the second example, using const
forces us to rethink how we're writing our code,
resulting in something that is actually a bit cleaner.
const hours = (new Date()).getHours();
const isMorning = hours < 13;
const greeting = isMorning ? 'Good morning!' : 'Good afternoon!';
ES6 also added a bunch of new functions to Array.prototype
, for looping through and manipulating data. These functions
let us write cleaner code by allowing us to write more declarative code. Declarative code describes what the code should do,
rather than how to do it. CSS and SQL are both examples of declarative coding taken to the extreme - 100% declarative languages.
The advantage of declarative code is that it hides the implementation details that we're not interested in. For example,
Array.protoype.forEach
allows us to do something for each item in an array, without worrying about the details of how
the loop actually works. Or, to put it another way, it increases the signal-to-noise ratio.
Of course, there is also a downside to declarative programming - you have less control. In the case of forEach
, you
can't have a loop counter that is incremented by 2 each time, for example.
Let's have a look at some examples of declarative coding using JavaScript's array functions, and why they're nicer than the traditional
way of doing things. We'll start with Array.prototype.forEach
, as it's conceptually the simplest.
Array.prototype.forEach
myList.forEach((item, index, list) => ...do something... )
Array.prototype.forEach
is a generic loop that will call your callback function once for each item in the given list.
The callback function will be passed the current item, the index of the current item, and the list itself. You can change
the list from within the loop, but I strongly recommend against it, as it makes your code more difficult to understand.
So, how does it work? Imagine we have a list of results.
const results = ['pass', 'pass', 'fail', 'pass'];
And we want to log them to the console. In traditional JavaScript, we might do something like this...
for (let i = 0; i < results.length; i++) {
const result = results[i];
console.log(result);
}
Using forEach
, that would look like...
results.forEach(result => console.log(result));
You can see we get as much signal as the first example, but with much less noise.
Now, this is a super-contrived example. The reason for this is that forEach
is a very generic function, which returns nothing.
The only thing it can do is produce side-effects. While this might be useful for something like logging, in most cases you're going to want
to use one of the other functions.
Array.prototype.map
myList.map((item, index, list) => ... return a boolean ...)
Array.prototype.map
gives us the ability to convert each item in an array into a new item, while leaving the original
array untouched. React apps use map
extensively to convert objects into HTML elements. You can also use it to extract
specific properties from objects in an array.
Say we had a function for creating a new element.
function createDiv(animal) {
const element = document.createElement('div');
element.innerText = animal;
return element;
}
And a list of animal names to be converted to divs.
const animals = ['bear', 'cat', 'giraffe'];
In traditional JavaScript, we could do
const elements = [];
for (let i = 0; i < animals.length; i++) {
const animal = animals[i];
const div = createDiv(animal);
elements.push(div);
}
But, using map
, we can just do
const elements = animals.map(animal => createDiv(animal));
As another example, imagine we had a list of people, and we wanted a list of their names.
const users = [
{ givenName: 'Phillip', familyName: 'Fry', occupation: 'delivery boy' },
{ givenName: 'Turunga', familyName: 'Leela', occupation: 'captain' },
{ givenName: 'Bender', familyName: 'Rodrigues', occupation: 'bending robot' }
];
Traditional JavaScript
const names = [];
for (let i = 0; i < users.length; i++) {
const user = users[i];
const name = `${user.givenName} ${user.familyName}`;
names.push(name);
}
Using map
const names = users.map(user => `${user.firstName} ${user.lastName}`);
We can actually simplify this even more, using object destructuring (which we'll go into shortly)
const names = users.map(({firstName, lastName}) => `${firstName} ${lastName}`)
Array.prototype.find/findIndex & Array.prototype.filter
myList.find((item, index, list) => ... return a boolean ...)
myList.findIndex((item, index, list) => ... return a boolean ...)
myList.filter((item, index, list) => ... return a boolean ...)
A super common task in JavaScript is to search for a particular item an array. Imagine we had list of accounts and we wanted to find the one belonging to a particular user. In traditional JavaScript, we could do something like...
let userAccount;
for (let i = 0; i < accounts.length; i++) {
const account = accounts[i];
if (account.name === name) {
userAccount = account;
break;
}
}
Array.prototype.find
greatly simplifies this.
const userAccount = accounts.find(account => account.name === name)
Similarly, if you would prefer to find the index of an item, rather than the item itself, you can use findIndex
.
function removeAccountById(accounts, id) {
const index = accounts.findIndex(item => item.id === id);
accounts.splice(index, 1);
}
Sometimes, you don't just want to find the first item that matches your criteria - you want to find all the matching items. For example, rather than finding an account that matches a particular name, we might want to find all the accounts that are currently active.
In traditional JavaScript, we could do this something like...
const activeAccounts = [];
for (let i = 0; i < accounts.length; i++) {
const account = accounts[i];
if (account.status === ACTIVE) {
activeAccounts.push(account);
}
}
Or we could do the same thing with Array.prototype.filter...
const activeAccounts = accounts.filter(account => account.status === ACTIVE);
Or, with object destructuring
const activeAccounts = accounts.filter(({status}) => status === ACTIVE);
Array.prototype.some and Array.prototype.every
myList.some((item, index, list) => ... return a boolean ...)
myList.every((item, index, list) => ... return a boolean ...)
Often, you want to check whether any item in an array meets a specific criterion. For example, imagine you have a form where the user needs to enter some contact details. They can add a physical address, an email address, a phone number, or a Twitter handle. You want to check that they've entered at least one before you submit the form.
In traditional JavaScript, that usually looks something like this
const inputs = document.querySelectorAll('.contact');
let hasContactMethod = false;
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
if (input.value) {
hasContactMethod = true;
break;
}
}
We can simplify this using Array.prototype.some
const inputs = document.querySelectorAll('.contact');
const hasContactMethod = Array.from(inputs).some(input => !!input.value);
Note we're using Array.from
to convert the NodeList
returned by document.querySelectorAll
into an actual array. Array functions will only work on true arrays, but just about anything that looks like an array can be converted to one.
On the other hand, sometimes we want to check that everything in the array meets the criterion. For example, imagine we have an order that contains a list of books. If all the books are digital, then we don't want to go through the shipping flow.
In traditional JavaScript...
let allDigital = true;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (!isDigitalMedia(item)) {
allDigital = false;
break;
}
}
You might notice that this is essentially the same loop as the previous example, with all the trues and falses around the other way.
As such, we could implement it using an inversion of some
.
const allDigital = !items.some(item => !isDigitalMedia(item));
This is pretty confusing though. Having to keep track of multiple negations usually makes code more difficult to understand.
Array.prototype.every
lets us do the same thing, without all the the !
s.
const allDigital = items.every(item => isDigitalMedia(item));
Or, even better
const allDigital = items.every(isDigitalMedia)
Remember, you don't need to define your callbacks inline. You can always define them somewhere else and just pass in the function name.
Array.prototype.includes
myList.includes(myItem)
myString.includes(substring)
We've already seen a couple of ways to check whether an item exists in an array, but here's another one. This one is often preferable, because it's much simpler. However, it does have a drawback. It uses reference equality to check whether the item you're searching for is the same as the one in the array. This means it's going to work great for strings and numbers, and not so much for objects.
Imagine that we want to check whether a user has a specific role. In traditional JavaScript, you might do something like...
const isAdmin = user.roles.indexOf(Roles.ADMIN) > -1;
Using includes
allows us to tidy this up a bit
const isAdmin = user.roles.includes(Roles.ADMIN)
This function can be particularly useful when combined with map
extracting a primitive value, as in the following example.
const someoneHasACat = users.map(user => user.pet).includes('cat');
And it also exists on String.prototype
, so you can use it check for the existence of substrings. The String protoype also
includes new methods to check specifically whether the substring occurs at the start or end of the string.
'elephant'.includes('ant'); // true
'elephant'.startsWith('ant'); // false
'elephant'.endsWith('ant'); // true
Array.prototype.reduce
myList.reduce((acc, curr) => { ... do stuff ... }, startingValue);
This is the most flexible of the array functions - all the others can actually be implemented using reduce
.
Unfortunately, it's also a little tricky to understand.
Unlike the other array functions, reduce
takes two parameters: a callback, which is called for every item in the
list, and a starting value, which is passed into the callback on the first call.
The callback also takes two parameters: the accumulated value, and the current value. The current value is the next item in the list.
The first time the callback is called, the accumulated value is the starting value passed into reduce
. Every other time,
it's the value returned from the previous pass of the callback.
The traditional example for demonstrating reduce
is to sum the values in an array, so let's look at that.
const numbers = [2, 7, 3];
const total = numbers.reduce((sum, number) => sum + number, 0); // 12
Let's break it down step by step
It turns out that summing the contents of an array isn't actually a thing that comes up that often though. Luckily, reduce
can be used for other things! The most common is turning an array "inside out". Say we had an API that returned a list of hospitals
that a doctor had shifts at. And for each hospital, it had a list of shifts at that hospital. We could find a list of all the
shifts the doctor had at all hospitals using reduce
.
const shifts = getHospitals((shifts, hospital) => shifts.concat(hospital.shift), []);
While reduce
is an undeniably powerful operator, it's important to remember that it can be quite difficult to
understand. A lot of the time it's going to be simpler to use a combination of other operators, even if it's more verbose.
Take this example (based on a true story). We've got a list of devices. We've also got a form with a list of checkboxes. Each checkbox correlates to a device in the list - so the first checkbox is for the first device, the second checkbox is for the second device, and so on. We want to get the list of codes belonging to the devices that the user has selected.
const selectedDeviceCodes = checkboxes.reduce((codes, checkbox, index) => {
if (checkbox.selected) {
codes.push(devices[index].code);
}
return codes;
}, []);
Alternatively, we could have implemented the same logic using a combination of filter
and map
.
const selectedDeviceCodes = devices.filter((device, index) => checkboxes[index].selected)
.map(device => device.code)
Here, we start with the list of devices. We filter the list by checking if the corresponding checkbox has been checked. Then we map the list of devices to their values. I certainly find this logic to be much easier to follow, but your mileage may vary.
Now, I'm not saying don't use reduce
at all, just make sure you've considered all options first.
And get someone else to have a look at your implementation, if you can.
While these new array functions provide a fantastic way to write cleaner code, there is one small gotcha you should be aware of:
using array functions with async/await
.
When you use async/await
in a traditional loop, like this...
async function setPrices(items) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
const price = await getPrice(item.id);
item.price = price;
}
}
...the loop will pause on each iteration, wait for the price to be returned, and then continue.
We can try and do the same thing using forEach
function setPrices(items) {
items.forEach(async (item) => {
const price = await getPrice(item.id);
item.price = price;
});
}
You'll notice that it's the callback function that is async
in this case - not the setPrices
function.
This is because
await
can only be used immediately inside an async
function. This means that
the callback function will fire off the request to fetch the price, and then immediately continue to the
next item, without waiting for the response to come back.
Now, in this particular situation, that's probably the behaviour that you want. This code is going to fetch all the prices in parallel, which is going to be faster than fetching them one-by-one, as in the traditional loop. However, you've got no way of knowing when all the prices have finished being fetched!
If we do need to know when the prices have all been fetched, we can take advantage of the fact that an
async
function always returns a Promise, which resolves when the function returns.
async function setPrices(items) {
const promises = items.map((item) => {
const price = await getPrice(item.id);
item.price = price;
});
await Promise.all(promises);
}
This code will
promises
arraypromises
array have been resolved before returningAnd, at the same time
promises
array, once the price has been set for the related item
However, sometimes this parallel behaviour isn't what you want. Instead, you want to wait for each request
to return before making the next request. In these cases, you're better off using a more traditional loop. However,
there is a more declarative version of the for
loop available in JavaScript.
for (const item of items) {
try {
await placeOrder(item);
} catch (e) {
console.log('Couldn\'t place all orders. Giving up.');
break;
}
}
This code will wait until each order has been successfully placed before placing the next order. If any order fails, it will
give up. This type of loop still removes some of the boilerplate associated with your basic for
loop, and will
work with anything array-like (including our NodeList
from above).
Two things to note, in order of importance
for ... of
loop. It is a different beast from a for ... in
loop, and should not be confused with
it. The for ... of
loop iterates through the items in an array-like thing. The for ... in
loop iterates
through the properties of an object. If you can think of a way to remember which is which, I'd love to hear it.
item
using const
, if you want. This is due to the way scope works in for
loops in JavaScript. Check out this HTTP203 video on for loops if you're
interested in understanding why.
Modern JavaScript also has a bunch of "shorthand" features - essentially ways to increase the signal-to-noise ratio of your code by removing boilerplate and repetition. Some of them can seem a little hard to wrap your head around at first. I strongly encourage you to learn how they work though, and then decide for yourself whether using them makes your code cleaner or not. If you do decide you like them, you might also want to consider adding linter rules enforcing their usage.
Probably the simplest to wrap your head around, the object property shorthand can be used when you have an object property whose name and value are the same. It allows you to omit the value, leading to less repeated code.
const name = 'Leia Organa';
// old JavaScript
const request = { name: name };
// with shorthand
const request = { name };
While this shorthand is usually straightforward to understand, it can get confusing when you're using a mixture of shorthand and non-shorthand values. In these cases, a bit of formatting can be very helpful.
// a bit confusing
const request = { method: 'GET', name, birthDate, accountNumber: accountId, accountType };
// better
const request = {
method: 'GET',
name,
birthDate,
accountNumber: accountId,
accountType
};
While the object property shorthand makes it easier to create objects, object destructuring makes it easier to extract things out of objects. Imagine that we have an account object like so...
const account = {
name: 'Ellen Ripley',
type: 'PHONE',
number: '34352';
}
In old school JavaScript, you could extract the properties into their own variables like so.
const name = account.name; // 'Ellen Ripley'
const type = account.type; // 'PHONE'
const number = account.number; // '34352'
Modern JavaScript, however, let's us do this!
const { name, type, number } = account;
console.log(name); // 'Ellen Ripley'
console.log(type); // 'PHONE'
console.log(number); // '34352'
This code will create three new variables - name
, type
, and number
. It will set the value of these
variables to the values of the properties of account
which have the same names.
We can also use destructuring when the property name is not the same as the variable name we want to use.
const { number: id } = account;
console.log(id); // '34352'
Note that the property name goes first, the variable being declared second. So here, we are declaring a variable named id
, which is
taking the value of account.number
. I personally find this a little confusing, and would think twice about using it.
We can also extract properties that are nested inside the target object.
const user = {
name: 'Princess Zelda',
contactDetails: {
type: 'TWITTER'
value: '@zelda'
}
};
const { contactDetails: { value }} = user;
console.log(value); // '@zelda'
You can even combine nested values with renaming values.
const { contactDetails: { value: twitterHandle }} = user;
console.log(twitterHandle); // '@zelda'
However, at this point, I would be seriously considering if this is really better than just using
const twitterHandle = user.contactDetails.value;
One place, however, where I think object destructuring is particularly useful, is in function parameters. Imagine we have a function that receives a config option like so...
function makeRequest(config) {
const method = config.method;
const url = config.url;
const request = { method, url };
if (method === 'POST') {
request.body = config.body;
}
}
We can simplify this using object destructuring!
function makeRequest({ method, url, body }) {
const request = { method, url };
if (method === 'POST') {
request.body = body;
}
}
Not only does this save us from assigning the variables manually, it also makes it clear which properties the config object should have.
But wait - there's more! Objects aren't the only thing that can be destructured: we can do the same thing with arrays!
For example, the new Object.entries
function returns the properties of an object as a array of arrays
containing the property name and value.
const account = {
name: 'Ellen Ripley',
type: 'PHONE',
number: '34352';
}
const properties = Object.entries(account);
console.log(properties); // [['name', 'Ellen Ripley'], ['type', 'PHONE'], ['number', '34352']];
Array destructuring can make processing these properties simpler.
const firstProperty = properties[0]; // ['name', 'Ellen Ripley']
const [name, value] = firstProperty;
console.log(name); // 'name'
console.log(value); // 'Ellen Ripley'
We can use array destructuring on function parameters as well.
const copyOfAccount;
properties.forEach(([name, value]) => {
copyOfAccount[name] = value;
});
Array destructuring can also be used to swap variables without using a temp variable.
Traditional JavaScript...
let leftPane = viewport;
let rightPane = devTools;
// traditional swap
let temp = leftPane;
leftPane = rightPane;
rightPane = leftPane;
console.log(leftPane); // devTools
console.log(rightPane); // viewport;
With array destructuring...
let leftPane = viewport;
let rightPane = devTools;
[leftPane, rightPane] = [rightPane, leftPane]
I don't think I've ever run into an actual use case for this, but you can totally do it, if you need to.
Rest and spread are essentially the same operator, used in different situations. They're used for converting groups of things into arrays or objects, and vice versa. Both are represented by three dots (...).
Rest parameters are used to convert an array into a list of arguments, or to convert a list of arguments into an array. This is helpful
when dealing with functions which can accept any number of arguments. For example, the built-in function Math.min()
takes
any number of numbers and returns the lowest.
console.log(Math.min(4, 63, -2, 0, -79)) // -79
Historically, this was pretty limiting. For example, you couldn't use Math.min()
to find the smallest of an array of numbers.
Instead, it was necessary to wrap the function up using apply
.
const numbers = [4, 63, -2, 0, -79];
const smallest = Math.min.apply(undefined, numbers);
(If you're not familiar with apply
, its primary purpose is to define the value of this
when a function
is called. For more information on apply
- and its close relative call
- check out
MDN's article
on Function.prototype.apply. For more information on this
and how this
-binding works, wait for the next
section!)
Now, while using apply
certainly works, it's not exactly the most elegant code. What is that undefined
doing
in the middle there? (We actually don't care about the value of this
in this example, so we could have passed anything in
as that first argument, which is even more confusing).
Rest parameters make this much nicer.
const numbers = [4, 63, -2, 0, -79];
const smallest = Math.min(...numbers);
Rather than stuffing around with esoteric functions on the Function
prototype, we can just use the ...
operator to convert the array into a list of arguments.
Rest parameters also work the other way - to convert a list of arguments into an array. For example, we could define a sum
function, which takes any number of arguments, and returns the sum of all of them.
function sum(...numbers) {
numbers.reduce((total, number) => total + number, 0);
}
console.log(sum(4, 9, 17, 23)); // 53
Generally speaking, I think rest parameters are more useful in the first case - passing an array into an existing function that doesn't accept an array. The second case essentially takes a function that works with an array, and makes it look like it doesn't work with an array. You'd want to think very carefully about why you're doing that.
The other thing that ...
can do is act as a spread operator. The spread operator converts an array into a kind of "array fragment"
that can then be used inside another array. We can use this for concatenating arrays, or for adding new values to an array.
const cats = ['Burmese', 'Siamese', 'Ragdoll'];
const dogs = ['Labrador', 'Husky', 'Kelpie'];
const pets = [...cats, ...dogs];
console.log(pets); /// ['Burmese', 'Siamese', 'Ragdoll', 'Labrador', 'Husky', 'Kelpie'];
const moreDogs = [...dogs, 'Shiba Inu', 'Beagle'];
console.log(moreDogs); // ['Labrador', 'Husky', 'Kelpie', 'Shiba Inu', 'Beagle'];
If it helps, you can think of the spread operator as creating a copy of your array, and stripping off the brackets, allowing you to stick multiple arrays together without ending up with them all nested.
The spread operator can also be used on objects. In that case, it essentially creates a copy and removes the curly braces. You can use this for extending objects with more properties.
const person = {
name: 'Amy Wong',
birthday: '2978-12-03'
};
const employee = {
...person,
role: 'intern'
};
console.log(employee); // { name: 'Amy Wong', birthday: '2978-12-03', role: 'intern' }
And, if you recall from the section on using idioms, the other thing you can use the spread operator for is creating copies! Just remember that it creates a shallow copy, with the nested objects passed as references.
// Array destructuring
const birbs = ['Canary', 'Rosella', 'Galah'];
const copyOfBirbs = [...birbs];
birbs.push('Cockatoo');
console.log(birbs); // ['Canary', 'Rosella', 'Galah', 'Cockatoo']
console.log(copyOfBirbs); // ['Canary', 'Rosella', 'Galah']
// Object destructuring
const postageDetails = {
height: 47,
width: 23,
cost: {
amount: 10,
currency: 'AUD'
}
};
const copyOfPostageDetails = {...postageDetails};
postageDetails.width = 58;
postageDetails.cost.amount = 22;
console.log(postageDetails); // { height: 47, width: 58, cost: { amount: 22, currency: 'AUD' } }
console.log(copyOfPostageDetails); // { height: 47, width: 23, cost: { amount: 22, currency: 'AUD' } }
copyOfPostageDetails
is a copy, so altering the width
of postageDetails
doesn't affect copyOfPostageDetails
. However, it's only a shallow copy, so copyOfPostageDetails
gets a reference to postageDetail
's cost
. As a result, changes to
postageDetails.cost
also appear in copyOfPOstageDetails.cost
.
The last topic I wanted to touch on in this section is default parameter values. Due to the nature of JavaScript, all function parameters are essentially optional. If a function expects an argument, and you don't pass anything in, it's simply undefined.
function sayHello(name) {
console.log('Hello', name);
}
sayHello(); // Hello undefined
As a result, it's usually a good idea to check that arguments have been set. If they haven't, you can either set them to some sensible default, or throw an exception.
function sayHello(name) {
name = name || 'world';
console.log('Hello', name);
}
sayHello('Captain Janeway'); // Hello Captain Janeway
sayHello(); // Hello world
Setting a bunch of default values can get a bit cumbersome, so modern JavaScript gives us a simpler way to do it.
function sayHello(name = 'world') {
console.log('Hello', name);
}
sayHello(); // Hello world
One small thing to remember - parameters with default values need to appear at the end of the argument list. Of course, this won't be that much of a problem, as we only write functions with one or two parameters, right?
Also, your default values absolutely can refer to previous parameters.
function registerUser(name, preferredName = name) {
createUser(name);
printNameBadge(preferredName);
}
The final part of modern JavaScript I wanted to talk about is arrow functions. By this point you've seen me using them all over the place. They provide a nice way to write callbacks inline - they're a little shorter to declare than normal functions, and a single line arrow function has an implicit return, saving even more characters.
const evenNumbersNormalFunction = numbers.filter(function (number) {
return number % 2 === 0;
});
const evenNumbersArrowFunction = numbers.filter(number => number % 2 === 0);
As you can see, the arrow function is shorter, and has a greater signal-to-noise ratio. However, arrow functions are special for another
reason too. To understand this, we need to look at the this
keyword in JavaScript.
Understanding how this
works can be one of the most challenging parts of learning JavaScript. The rules themselves aren't
super complicated, it's just that there are a lot of them. Often, you'll see people working around this
by using code like
const that = this;
While this hack works (and is used a lot), it's not really clean code. We're adding variables that don't add any meaning. And, in older code,
before the introduction of const
, there was nothing stopping us from assigning that
to something else. Today,
there are plenty of ways to manage this
without resorting to hacks.
So how does this
work? As a general rule, in JavaScript, this
refers to the object that called the function.
However, there are a few ways to override that. Also, it's important to remember that this
refers to the object that
called the function, not the object that defined it.
const uhura = {
givenName: 'Nyota',
familyName: 'Uhura',
sayHello: function () {
console.log('hello', this.givenName, this.familyName);
}
};
const scully = {
givenName: 'Dana',
familyName: 'Scully',
sayHello: uhura.sayHello
};
uhura.sayHello(); // Hello Nyota Uhura
scully.sayHello(); // Hello Dana Scully
You'll notice that even though scully.sayHello
calls uhura.sayHello
, this
refers to
scully
, not uhura
. This can be confusing, especially if you've come from a language
that doesn't work this way, but it makes a lot of sense in a language that deals with event handlers.
const button = document.getElementById('special-button');
button.addEventListener('click', function () {
console.log(this); // button
});
However, like I said, there are a bunch of ways to override this default behaviour.
this
refers to the window
object.
window.name = 'Global demo';
const person = {
name: 'Sarah Connor',
sayHello: function () {
console.log('Hello', this.name);
}
}
const sayHello = person.sayHello;
sayHello(); // Hello Global demo
I mean, this isn't really overriding the default behaviour, just kind of dealing with a situation where the default
behaviour is unclear, because there is no obvious caller.
this
is undefined. Any code written inside a module
is in strict mode, as is any code with a 'use strict';
declaration.
const person = {
name: 'Sarah Connor',
sayHello: function () {
'use strict';
console.log('Hello', this.name);
}
}
const sayHello = person.sayHello;
sayHello(); // Uncaught TypeError: Cannot read property 'name' of undefined
call
or apply
, then this
refers to the first argument
of call
or apply
.
const person = {
name: 'Sarah Connor',
sayHello: function () {
console.log('Hello', this.name);
}
};
const anotherPerson = {
name: 'Katniss Everdeen'
};
person.sayHello.call(anotherPerson); // Hello Katniss Everdeen
bind
, then this
refers to the first argument of bind
.
This can't be overridden via call
or apply
const person = {
name: 'Sarah Connor',
sayHello: function () {
console.log('Hello', this.name);
}
};
const anotherPerson = {
name: 'Katniss Everdeen'
};
const boundHello = person.sayHello.bind(anotherPerson);
boundHello(); // Hello Katniss Everdeen
boundHello.call(person); // Hello Katniss Everdeen
new
keyword, then this
refers to a brand new object,
that will be returned by the function. This can't be overridden by binding the function.
function Ship(name) {
this.name = name;
}
// 1
const objectShip = Ship('Planet Express');
console.log(objectShip.name); // Uncaught TypeError: Cannot read property 'name' of undefined
// 2
const constructedShip = new Ship('Planet Express');
console.log(constructedShip.name); // Planet Express
// 3
const boundShip = Ship.bind({ name: 'Millenium Falcon' });
const constructedBoundShip = new boundShip('Planet Express');
console.log(constructedBoundShip.name); // Planet Express
In the first example, we didn't use the new
keyword, so within Ship()
, this
refers to
window
. Additionally, Ship()
doesn't actually return anything.
In the second example, using the new
keyword turns Ship
into a constructor. Constructors create a brand new
object, and return it implicitly. (You don't need to have an explicit return statement in a constructor.) Within a constructor,
this
refers to the newly created object.
In the third example, we create a new function, boundShip
, but calling bind
on Ship
.
We call boundShip
as a constructor. Within the constructor, this
still refers to the newly
constructed object - not the object we passed into bind
.
All of this changes with arrow functions though. Arrow functions essentially don't bind this
.
const uhura = {
givenName: 'Nyota',
familyName: 'Uhura',
sayHello: () => {
console.log('hello', this.givenName, this.familyName);
}
};
uhura.sayHello(); // Hello undefined undefined
Instead, they'll use the this
from the context that they're declared in.
class Person {
constructor(name) {
this.name = name;
this.sayHello = () => console.log('Hello', this.name);
}
}
const uhura = new Person('Uhura');
uhura.sayHello(); // Hello Uhura
So how does this
-binding in arrow functions behave in our exceptional circumstances?
this
will refer to the window object in
non-strict contexts, and be undefined
in strict contexts.
call
or apply
has no effect. this
will refer to the same
thing it would have referred to if you didn't use call
or apply
class Person {
constructor(name) {
this.name = name;
this.sayHello = () => console.log('Hello', this.name);
}
}
const uhura = new Person('Uhura');
const katniss = { name: 'Katniss' };
uhura.sayHello.call(katniss); // Hello Uhura
bind
also has no effect.
class Person {
constructor(name) {
this.name = name;
this.sayHello = () => console.log('Hello', this.name);
}
}
const katniss = { name: 'Katniss' };
const boundPerson = Person.bind(katniss);
const uhura = new boundPerson('Uhura');
uhura.sayHello.call(katniss); // Hello Uhura
new
on an arrow function.
const Person = (name) => ({ name });
const person = new Person('Uhura'); // Uncaught TypeError: Person is not a constructor
Semantic HTML refers to HTML that uses elements that have intrinsic meaning. For example, if you're creating something for a user
to click on, then you should use a <button>
, rather than a <div>
. It's often spoken about in
the context of accessibility, but it's important to remember that accessible code isn't just about making your code work with a
screen reader.
So what does semantic HTML look like?
Every page should contain some landmarks. Landmarks are things like headings, sections, navigation, and sidebars. Using these types of elements is generally essential for good design - imagine trying to read this page if it wasn't broken up into sections, or didn't have any headings!
If we use semantic HTML to demark these elements, then our page will still be navigable in situations where we can't rely on CSS - such as with a screen reader; but also on a slow internet connection, where the CSS hasn't loaded, in a browser's Reader mode, or when user is otherwise using custom stylesheets, or when the next developer is reading the HTML in an IDE.
Landmark HTML elements include heading tags (<h1> - <h6>
), <header>
, <footer>
,
<nav>
, <main>
, and <aside>
elements. Not every page is going to have all of
these features, but if your page has any of them, then you should use the appropriate element.
This fits with the clean coding principles we were discussing earlier - communicate your intent by using meaningful names.
If you're building a SPA with multiple logical pages, then you should give each page a distinct title via the
<title>
element, within <head>
. For example, on Twitter, the feed page has the title Latest Tweets / Twitter
, while the
notifications page is Notifications / Twitter
. These titles help screen reader users know when they've navigated to a new
page, but they also help sighted users distinguish between browser tabs, as the <title>
element is used as the tab
title. The title should include the specific page name, followed by your app name. (Put the app name last. The tab title is likely to be
truncated, and sighted users can use the favicon to distinguish between apps.)
As well as having some landmarks, we should make sure we have appropriate descriptions for our elements. This is most relevant for
<input>
elements (where we're asking the user to do something), and images (which might not be available, due to network
conditions, or device restrictions, or might not be visible, due to the user's vision, or lighting, or screen glare).
Every input
element should have an obvious label that remains visible while the user is interacting with the element.
This means that placeholder text that disappears when the input has focus is not enough. Disappearing labels can be particularly
difficult for users with cognitive difficulties, but they also make life harder for users who are using your app while distracted
by something else. If you are particularly enamoured of inline labels, try something like Google's Material Design labels, which
start inline, but move above the input once you start typing.
Ideally, you should use a <label>
element to label your <input>
element. The <input>
should be a child of the <label>
or they should be linked via for
and id
attributes.
<label>
<input type="checkbox"> Yes! Please send me spam!
</label>
<label for="favourite-book">What is your favourite book?</label>
<input type="text" id="favourite-book">
Linking labels and inputs like this means that a click on the <label>
is treated like a click on the
<input>
. For checkboxes and radiobuttons, this means the item will be selected just by clicking the label.
This increases the clickable area, which is particularly helpful on small screens. It also provides us with opportunities to style
our radiobuttons and checkboxes in an accessible way, as we can add the styling to the label.
Images are the other types of element that are super important to describe. The rules for describing them are a bit more complicated
though. First of all, all <img>
elements should have an alt
attribute. Sometimes this
attribute will be empty, but it's still important to include it. Otherwise, screen readers will read out the filename.
If your image is illustrating a point, or providing information, then it needs a description. That description can be in the
text of your page. If there is no descriptive text on the page, then you need to provide a description in the alt
attribute. This isnt just for accessibility either - it also helps with search engine optimisation.
As seen in the image below, giraffes must bend down awkwardly to drink.
Horses, on the other hand, don't have that problem
...
<img src="images/giraffe-drinking.jpg" alt="">
...
<img src="images/horse-drinking.jpg" alt="horse drinking from beer glass by picking the glass up in its teeth">
(Giraffe drinking from the pond flickr photo by Tambako The Jaguar shared under a Creative Commons (BY-ND) license.)
Make sure your description describes what the picture is about in the context that it's being used in. In our horse example above, the important part is that the horse isn't bending down to drink - it's picking up the glass in its teeth.
As well as being descriptive, your alt
text also needs to be succinct (which can seem like a tall order!). Screen readers will only
read out 125 characters, so make sure you don't waste any! Especially make sure that you don't start your descriptions with "image of" or
"picture showing". The screen reader will announce that it's an image, and a search engine bot will know from the img
tag. Adding this
information to the alt
text is repetitive and boring.
On the other hand, if your image is purely decorative, it doesn't need a description.
<img src="images/divider.svg" alt="">
(It would be even better to add this divider as a background image of an hr
element.)
Icons are a kind of special case that kind of crosses the line between informative and descriptive. While icons can convey meaning, you usually shouldn't rely on them by themselves. Instead, icons should be accompanied by descriptive text, that is visible to all users. The reason for this is that icons are not really very good at adding meaning. They usually rely on a picture of a physical object to convey an abstract concept. And, they're often so highly stylised, that it's not obvious what it's actually a picture of, unless you already know.
I'd recommend that you treat icons like acronyms in writing - only use them if you've already defined what they mean. In writing, this means that you should provide the expanded version of the acronym the first time you use it. For example, we might say "In order to render a page, the browser constructs the DOM (document object model)". Unfortunately, in an app, there's no real way to know where a user is going to encounter your icon first. So, best practice is to include text with them everywhere.
Sometimes though, it's not going to be practical to add the text. In these cases, you'll need to use your best judgement. It's more ok to leave off the labels when space is extremely limited, or you're dealing with expert users. So, if you're building a game, with limited space for controls, and users who are going to invest time in learning the controls, and who play frequently enough to remember them, you can go to town with icon-only buttons. A banking app, which users are only going to interact with a few times a month - not so much.
For more information on the topic (including some pratical exercises) check out WebAIM page on Alternative Text.
Finally, sometimes there's just no good way to convey information non-visually. For example, HTML doesn't provide us with an obvious way to associate a label with a group of radio buttons.
This is the kind of thing we need ARIA for! We can use an ARIA role, and the aria-labelledby
property to
provide a couple of extra hints to our radio buttons, just for screen readers.
<div id="tacos-label">Tacos</div>
<ul role="radiogroup" aria-labelledby="tacos-label">
<input type="radio" name="tacos" id="soft-shell">
<label for="soft-shell">Soft shell</label>
<input type="radio" name="tacos" id="hard-shell">
<label for="hard-shell">Hard shell</label>
<input type="radio" name="tacos" id="why-not-both">
<label for="why-not-both">Why not both?</label>
</ul>
For more information about aria-labelledby
, and a tonne of use cases and examples, check out
MDN's page on aria-labelledby.
Accessibility isn't just about semantic HTML though. You also need to think about how users navigate around your app. Most apps are designed with a user using a computer with a mouse in mind. Designs for mobile phones and tablets are tacked on as an afterthought (or not at all). This probably isn't the best approach to take though, as most websites get more traffic from users on phones (and tablets) than traditional desktop and laptop machines.
As you can see, mobile usage accounted for almost 10% more users than desktop usage worldwide in December last year, and the gap is widening. In Australia, the picture is a little different, but not significantly.
While desktop usage is a bit higher in Australia, it's still dropping. And tablet usage is also much higher than the global average. Combining mobile and tablet usage, touchscreen devices still account for around 10% more of the market than traditional computers.
On top of this, there is an unknown number of users employing non-standard input devices, such as accessibility devices, game controllers, or even watches.
The good news is that most non-standard devices function like keyboards. So if you can get your site working for touchscreens and keyboards, you should be OK for the vast majority of people. (Honestly, I wouldn't even bother too much with mouse users, as an app which works for keyboards and touchscreens will work with a mouse. Maybe just add some hover states.)
So what are the things you should keep in mind when designing for keyboard and touchscreen users?
You need some kind of indicator of which element currently has focus. The browser will give you this one for free.
The HTML standard recommends just using the browser default indicators, but I find there are a couple of problems with this advice. For a start, most designers don't like them - they appear differently on different browsers, and they don't match with the site's theming. They're also completely inaccessible if your site happens to use background colours that are similar to the focus indicator colour (and the colour changes between different versions of the same browser, and even between different input types in the same version of the same browser).
The good news is that you can fairly easily replace the default indicators with your own. You can use an outline, like the browsers do, or change the border or colour of the element. Just make sure you're not relying solely on changing the hue of the colour, as this won't be visible to colour-blind users. For inputs, I find adding a thicker border usually works well.
For links, my usual go-to is a dashed outline in the current colour.
/* New focus style will replace the default style in Chrome and Safari */
.link:focus {
outline: 1px dashed currentColor;
outline-offset: 2px;
}
/* You need to remove the Firefox focus indicator manually */
.link:-moz-focusring {
outline: none;
}
currentColor
is a CSS variable that references the color
attribute of the current element.
Users should be able to navigate between interactive elements using the tab key. By default, this will Just Work
with the native interactive elements - <a>
, <input>
, <button>
,
<select>
, and <formarea>
.
When building custom controls, the ideal approach is to construct them out of native elements that have similar functionality
(like our power button above). Sometimes, though, your control just isn't going to map well to existing HTML controls. In those
cases, you can use tabindex="0"
to add an element to the tab order. Just make sure you don't add it to elements that
aren't interactive!
If you have a multi-page SPA (which, yes, sounds like an oxymoron) you need to move the focus back to the top of the page manually when the user navigates to a new route. Otherwise, the next tab will just continue on from where you were. And, given that the rest of that particular page no longer exists, that's probably going to be the footer.
My recommendation would be to have an <h1>
element at the start of each page. (I mean this is just good design,
but is also helpful here). Give your <h1>
a tabindex
of -1, so it can be focussed with JavaScript,
but can't actually be tabbed to. Then, have some kind of interceptor that moves the focus to the <h1>
whenever
the route changes.
<h1 tabindex="-1">Notifications</h1>
// On route change
const heading = document.querySelector('h1');
heading.focus();
Remember, there is no way to hover on a touchscreen! While it's great to add hover styles for mouse users (and focus states for keyboard users), you need to make sure these are not the only way of indicating that something can be interacted with. Links, for instance, should also be underlined. And buttons should... look buttony? Design trends are kind of all over the place on this one, but, at a minimum, your buttons should all look similar, and they should look different from things that aren't buttons.
On top of this, you can't rely on hover to activate things like menus and tooltips. They also need to work on click. In fact, I would recommend always using click and ditching hover altogether. Not only does it not work on touchscreens, or with a keyboard, it can be difficult to use for people
hover
media query. It's not supported in IE, but I don't think it's going to matter that much if your IE
users just get the non-hover version of your site.
Finally, the web has been around for over 30 years now. There are well-established conventions for interacting with pages. If you use semantic HTML, you will follow these conventions automatically, and users will learn how to use your app far more quickly. And they'll probably enjoy it more. Some examples of these things are:
<a>
element for links, the cursor will automatically change to a hand, and the link destination
will appear in the browser status bar on hover. The user can open the link in a new tab by ctrl-clicking, or using the context
menu. Pressing enter while the link is focussed will activate it. (Furthermore, pressing space will
not open the link. This behaviour is not preserved if you use a <button>
for your link.)
<button>
element, the onclick
handler will be fired if the user presses enter or
space while the button has focus, so it's automatically usable by a keyboard user.
<form>
element has focus, pressing enter or space will
submit the form.
<label>
element is treated as a click on the linked <input>
element.
When writing HTML, it is important to remember to choose your HTML element based on the functionality of the element, not its
appearance. For example, for some reason, designers really like styling links as buttons (and vice versa). I'm sure they have their
reasons, and that's fine. Let them have at it. You, however, should still use <a>
elements for links, and just
CSS the heck out of them. HTML describes what our app does. CSS describes how it looks.
And finally, I hope I've convinced you that accessibility isn't some annoying thing you do at the end to cater for some small segment of the population. It's about building an app that works well for all your users, in all the situations they find themselves in. Furthermore, building accessibilty through semantic HTML isn't just good for users - it's good for developers as well. It results in code that is cleaner, and easier to understand. Building an accessible website doesn't have to be time-consuming or expensive - it can actually be faster than the alternative.
Out of CSS, HTML, and JavaScript, I think CSS is the thing people struggle with most. If you're looking for work as a frontend developer, strong JavaScript skills are important, but mastery of CSS and HTML is what will make you really stand out.
Part of the reason that CSS is so difficult is that it's a completely different paradigm to most programming languages. While knowing C++ or Java will give you a huge leg-up with learning JavaScript, it won't help at all with learning CSS.
Another factor is the fact that CSS seems to lack underlying principles. Learning CSS often feels like just memorising property names and allowed values, then learning a bunch of tricks to achieve what you want. This makes learning CSS very time consuming, but also very tedious.
Luckily, modern CSS is much more systematic than earlier versions. Rather than just adding new features willy-nilly, modern CSS has underlying models that are shared between different properties. This makes learning new CSS features much quicker and easier.
So today, rather than just explaining how to achieve various effects with CSS, we're going to have a look under the covers at the principles and models that make CSS work.
In CSS, everything is a box. Boxes are rectangular. They have a width and a height. Boxes are divided into four areas:
A box also has a box-sizing
, which determines how the width and height are calculated. The allowed values
are border-box
and content-box
.
border-box
content-box
By default, all elements have box-sizing: content-box
. Except in IE, which uses box-sizing: border-box
,
because of course it does. Because of this, it's a good idea to set the box-sizing manually on any project you're working on.
What might be surprising is that most people actually follow IE's example and use box-sizing: border-box
. You usually
care about the total size of an element, regardless of how much padding it has, and border-box
makes the
calculations a bit easier. You can override the browser's default box-sizing with the following bit of code.
*, *::before, *::after {
box-sizing: border-box;
}
Overriding the browser's default styling like this is called a CSS reset. Resets are commonly used to ensure defaults are consistent across browsers. The selectors used are as generic as possible, so that you can easily override them in your application if you need to. You can write your own CSS resets, or you can just grab one someone else has created. To see the kind of thing that normally goes in them, check out Andy Bell's CSS reset on dev.to.
In CSS, individual elements are placed on the page according to normal flow. Each element is placed immediately adjacent to its previous sibling (respecting margins) in one of the two directions - inline, or block.
The inline direction is the direction that you write words in a sentence. On a page written in English, the inline direction is left to right, across the page. On an Arabic page, it's right to left. On a Japanese page, it's top to bottom, down the page.
The block direction is the direction that you add paragraphs to the page. On a page written in English or Arabic, it's top to bottom, down the page. In Japanese, it's right-to-left, across the page.
Why do we use the terms inline and block, rather than just saying across and down? Because we want CSS to be as language-agnostic as possible. It's not actually that good at it currently - just ask anyone who's ever tried to internationalise a page to a non-left-to-right script. But we are moving towards a more language-agnostic CSS, and it's good to get the hang of these things sooner rather than later.
So how do we know if an element should be added in the inline direction, or the block direction? Each element has a display
property of either block
or inline
. (Technically, there are a few more display
properties, as
we'll see shortly. But they can all be thought of as a type of block
, or a type of inline
). Elements with
display: inline
are added in the inline direction. Elements with display: block
are added in the block
direction. span
is your default inline element, with no other semantics. div
is your default block element.
The displ
can be overridden in CSS.
The display property controls more than just how the element is placed on the page though. Block and inline elements have other distinct properties and behaviours. Block elements are a little easier to understand conceptually, so let's start with them.
As we already discussed, block elements are added in the block direction. Each block element is on a row by itself - the next element will be placed below the block element, whether it is block or inline.
The size of a block element is determined by its width and height (plus the padding and border, if you're still using
content-sizing
, despite everything we just discussed). If the width of the element is auto
(which is the default value), then the element will be as wide as its parent element. If the height is auto
,
then the element will be as tall as it needs to be to fit all its content, given the current width.
If you give your element a width and height that is too small to fit the contents, then it'll overflow, and probably look terrible. This overflowed contents won't affect the placement of the next element though - it'll just get covered up.
One small gotcha with block layout is margin-collapse. In some situations, the margins of two adjacent block elements will overlap, resulting in a single margin that is the same size as the larger one. This is due to HTML's origins as a document delivery system - author's didn't want to end up with double margins around headings, or the last paragraph on a page. It also means that an empty element will disappear completely, rather than hanging around as a margin-ghost.
If you think you've been affected by margin-collapse, or you just want to know more about it, check out Rachel Andrew's article on CSS margins.
Inline layout is a bit more complicated than block layout. Inline elements behave like text - they are laid out side-by-side in the inline direction, until they get to the end of the line, and then they wrap to the next line.
In order to layout inline elements (and text), the browser creates a line box. The line box is a container that the browser uses to place elements - it doesn't appear in the DOM, and it can't be styled. It's kind of like adding pencil lines, that you erase once you've finished writing. Inline elements (and text) are placed in the line box, until the next element won't fit. Then the browser creates a new linebox, directly below the previous one, and adds the remaining elements to the new line box.
The width of the line box is the width of the parent element's content box. (In vertical scripts, this will actually be the height. I'm just going to assume a horizontal script here though, because, honestly, this stuff is already confusing enough.) The height of the line box is determined by its content. If the line box only contains text, it'll be based on the font size. If it contains non-text elements, it'll be based on the height of the elements. If it contains a mixture or text and non-text, or a number of different font sizes, then it's up to the browser to decide what height to use. It'll probably pick something slightly bigger than the biggest thing in the line box.
If you need to, you can set the height of the line box yourself, using the line-height
property. If you set the
line-height
of a block element, it will set the minimum height of all line boxes inside that element.
If you set line-height
on an inline element, then that height will be used in the line box height calculation, along with all the
fonts and stuff. In the example below, you can see that changing the line-height for one of the elements doesn't change the
height of any elements, but it changes the spacing between the lines. You can also see that the line-height of each line box
is calculated independently, depending only on the elements in that particular line box. Because of this, getting the line spacing
you really want can be surprisingly difficult in CSS.
The width and height of an inline element are determined by the content of the element. You can't set them manually. You can, however, add padding and margins. Paddings and margins in the inline direction will add space around the element, as you would expect. Paddings in the block direction will actually add space around the element, but if it makes the element taller than the line box, it will overflow. As a result, you won't be able to see the padding, unless you add a border or a background colour. Margins in the block direction have no effect.
If the line box is a different size than the height of your element, you can control whether your element is aligned to the top,
middle, or bottom of the line box using the vertical-align
property. (And yes, it's called vertical-align
even in situations where it's controlling the horizontal alignment. I told you CSS wasn't quite there yet with internationalisation.)
The vertical-align
property is only going to have an effect on inline elements, in a line box that isn't the same size
as their own height. This is why it never actually does what anyone wants it to when they're trying to centre-align things.
Now, you might have noticed that there are some disadvantages to the inline/block system. It conflates the sizing with the layout. What if you want elements to sit side-by-side in the inline direction, but you also want to control their size?
display: inline-block
was introduced to deal with this situation. An inline-block element is laid out like an
inline element, but can be sized like a block element.
Over time, other display
properties were added too. table
lays out an element like a table,
flex
gives us a flexible one-dimensional layout, and grid
gives a flexible two-dimensional layout.
Of course, it's possible you might want any of these to exist in an inline context, so we also have inline-table
,
inline-flex
, and inline-grid
.
At some point, someone realised that it was kind of silly to define two new display properties any time we wanted to add new
display functionality. It makes a lot more sense just to treat how the element is laid out and how it behaves internally as
two different things. So now, the display
property can take two values - the outer display value, and
the inner display value.
The outer value determines how the element itself is placed in the flow. The inner value determines how the element's children
are laid out. So display: block
is now display: block flow
and display: inline-flex
would be
display: inline flex
.
No browsers actually support the new two-value syntax yet, so don't go using it. However, it is a good model of how they implement the existing values, so I think it's a good thing to understand.
something about how children are laid out inside their parents
Ok, so now we understand how an individual element behaves, and how elements are laid out in two dimensions. But CSS actually allows us to lay things out in three dimensions.
Everything we've just talked about allows you to do some pretty nifty stuff. But there are some serious limitations to the normal flow. Elements can really only be positioned relative to their parent, or their immediately previous sibling. There's no way to co-ordinate the layout of several elements at once.
In the early days of CSS (and even before CSS was invented), we got around this by using table
elements.
More recently, we've moved to 12-column CSS layout tools, like Bootstrap's grid. Both of these have some serious disadvantages
though. Tables are not even a bit semantic, and require you to add a heap of extra HTML. They're also not responsive
(beyond being able to specify column widths in percentages). Bootstrap requires you to add a bit less extra HTML, and it
adds the ability to adjust the layout based on screen size. You're still limited to percentage widths though, you can only
change the layout based on the viewport size (not based on the size of the parent container), and it only supports layouts
at five different viewport sizes.
But don't stress - modern CSS now gives us a bunch of different options for controlling layouts. These options are pure CSS (you don't need to add extra HTML), and they are completely responsive.
One thing we should note before we start though. These new layout properties do not give you complete control over how your page is laid out. Instead, it gives you a way to communicate some guidelines to the browser, and the browser will work out the best layout, based on your requirements. This, in fact, is how all CSS works - it was never intended for specifying pixel-perfect layouts. Which makes a lot of sense - you have no way of knowing what kind of device your page is going to be displayed on, and it would be very tedious to try and specify every possible layout. Your time writing CSS is going to be a lot happier if you remember that the web is a dynamic medium, and that CSS is specifically designed to support that dynamicism.
display: flex
creates a flexible, one-dimensional layout. Setting display: flex
on an element
creates a flexbox inside that element. A flexbox is kind of like a line box, but with super powers.
You can control the direction that your flexbox runs using the flex-direction
property. There are four possible
values.
row
row-reverse
column
column-reverse
Why do we use row
and column
instead of block
and inline
?
I have no idea. I suspect it's because the spec was written before the serious efforts at standardising CSS properties
began.
By default, the browser will try to fit all your items into a single flex box. (We'll go into how it does this in a bit).
If you would prefer it to wrap (like a line box), then you can set flex-wrap: wrap
.
For the purposes of this discussion, let's assume we're working with a row. We'll be calculating the height of a row, and the spacing between rows. If we were working with a column, then we could use the same rules to calculate the width of the column and the spacing between columns.
Things start out relatively simple - by default, each row will get the same fraction of the height of the flex container. If all your items fit in one row, that row will be 100% of the height of the container. If you need two rows, then each row will be 50% of the height of the container. And so on, and so forth.
There are two ways to change this. Firstly, if an item is too tall for its row, then its row will grow taller, and the other rows will shrink. An item could be too tall because it has a height set on it, or because it has a lot of content.
The second way is to change the align-content
property of the flex-container.
By default, align-content
is stretch
, meaning each row will
stretch to fill up the available space.
If we change it to any other value, then the height of each row will be the height of the tallest element in that row. The rows will then be spaced according to the following table.
| Value | Effect | Diagram |
|---|---|---|
| flex-start/start | Rows will be placed towards the start of the container |
|
| flex-end/end | Rows will be placed towards the end of the container |
|
| center | Rows will be placed in the centre of the container |
|
| space-between | The first row will be at the start of the container, the last row will be at the end of the container. The remaining rows will be spaced evenly between them. |
|
| space-around | Rows are spaced evenly in the container. The space between the container and the first row, and the container and the last row, is half of the space between adjacent rows. |
|
| space-evenly | Rows are spaced evenly in the container. The space between the container and the first row, and the container and the last row, is the same as the space between adjacent rows. |
|
| stretch | If none of the elements have a height specified, then all the rows will be of equal height, stretched to fill up the available space. If any elements have a height specified, then those elements will have their given height, and the remaining space will be spread out equally between all rows, including the ones that already have some height. This can lead to some unpredictable behaviour (try adding heights to the example via dev tools). |
|
Remember, terms like start and end are dependent on the flex-direction
.
start
and end
are new values, intended to replace flex-start
and flex-end
,
to give us a more flexible way of defining alignment and justification (ie so we can use them in grid too!). They're currently
only supported by Firefox.
stretch
isn't supported in IE and some versions of Edge.
Check the MDN article on align-content
for up-to-date browser compatiblity data.
Just like we can use vertical-align
to place an item relative to a line box, we can use align-self
to
place an item relative to the flex-box. By default, align-self
is stretch
, which means the item
will stretch to fill the height (or width) of the entire flexbox. You can also set it to flex-start/start
,
flex-end/end
, or center
.
And if you don't want to set the alignment on every item individually, you can use align-items
on the flex container
to align all the children at once.
While it might seem like align-content
and align-items
do the same thing, there is an important difference.
align-content
adjusts the spacing between the flexboxes (like line-height for line boxes). align-items
(and
align-self
) controls the placement of the items within the flexbox (like vertical-align
, except
that it will actually do that centre-alignment for you).
We can also control how the flex items are laid out in the inline direction. We can do this via the justify-content
property.
justify-content
takes the same values as align-content
, and has similar browser support.
Are all these align and justify properties confusing? Yes. Do I have a good way of remembering them, other than just practice?
Not really. You can try to remember that "justify" is like justifying text, and goes in the same direction. And "align" is like
vertical-align
? I guess? I can't help you with "content" and "items", except to say that "x-items" can be overridden
by "x-self", because it refers to the items individually. "x-content" refers to the items as a whole, so a single one can't be set
differently. flex
lacks a justify-items/justify-self
, because it only operates in one dimension.
If you find yourself needing to do that, then you should think about using grid
.
And, if you want to practice using these properties, I recommend checking out
Flexbox Froggy - it's a cute game where you use flex
properties to position frogs on lilypads.
So far, we've looked at positioning things with flex
. However, the real magic of flex
comes from how
items within a flexbox are sized. And I'm not going to lie, it involves a bit of browser voodoo.
First up, let's have a look at size in the cross-axis direction. The cross-axis direction means the direction perpendicular to the flow of text. If we're working with a row going across the page, then we're talking about the height. If we're working with a column going down the page, then we're talking about the width. For the sake of simplicity, let's assume it's a row, and we're talking about height.
Luckily, this one isn't too complicated.
align-items
or align-self
), then your item
will have the given height and alignment.
stretch
and fill
the entire height of the container.
height: 200px; align-self: flex-end
height: 200px
align-self: center
with lots and lots and lots and lots and lots and lots of content.
align-self: center
and a small bit of content
If your item contains dynamic content (ie, you don't know how big it needs to be in advance), then I recommend setting the alignment to whatever you like, and letting the browser work out the height. It will almost certainly do a good job.
You should also make sure that you set the alignment if your element is an image. Otherwise, the default alignment of
stretch
is going to completely trash the aspect ratio.
This is easily corrected by adding align-self: flex-start
.
The size along the main axis is a lot more complicated to work out, but you also have more control over what happens. So I guess it balances out? Assuming we're still working on a horizontal row, this is going to be the width of the elements.
The width of a flex item depends on
For the sake of simplicity, let's start with a flexbox with a single item in it. The flexbox is much bigger than the item.
The element will stretch out as big as it can get, without wasting any space. If the element contains only inline elements (or text), it
will be wide enough that everything can fit in a single line box. This is called the max-content
width.
(And max-content
is an allowed value, anywhere that you can set a width
or height
).
Let's add some more elements.
Now, there's not enough room for all the elements at their max-content width, so they've all shrunk a bit. The elements will all shrink at the same rate. In our case, if all the elements were at their max-content width, they would take up about 2000px. The container is only about 650px wide though. So each element will shrink by 650 / (2000 - 650) = 40%. (I fudged the numbers a litte, so don't go looking too hard in the dev tools, but hopefully you get the idea.)
If we don't want the element to stretch out to its max-content width, we can set a size manually, via the flex-basis
property. The flex-basis
property will refer to the width of an element in a row, or a the height of an element in
a column. If we have a single item, in a large container, with a flex-basis
, then its width will be its flex-basis
value.
If we have multiple items with flex-basis
, and they don't all fit, they'll all shrink at the same rate, just
like before.
One thing to note - if you set a width
, rather than a flex-basis
, then the flex-basis
will
be the same as the width
. If you set a width
and a flex-basis
, then the
width
will be ignored. flex-basis
is also what's used by the flex
shorthand property we're
going to look at shortly. As such, I'd recommend using flex-basis
over width
, but it's not going to
make a big difference.
Oh, and if you're using columns instead of rows, replace "width" with "height" everywhere above!
Ok, so, by default flex-items are going to try to be their flex-basis
width. If they don't have a flex-basis
,
they will try to be their max-content
width. If all of the flex items can't fit at their preferred width, they will
shrink at the same rate.
But what happens if there's nowhere near enough room and they have to shrink a lot. Well, it turns out that shrinking is not a complete free-for-all. There are some rules.
No element will shrink below its min-content
width. The min-content
width is the smallest possible width that
the element can be, while still fitting its content. If your element just contains text, then it will be the size of the longest word.
If your element contains other elements, it will be the size of the largest child element.
You can also set a min-width
on your element.
No element will shrink below its min-width
(or min-height
, in a column). If the min-width
is
smaller than the min-content
width, then the element won't reach its min-width
- it'll stop shrinking
at its min-content
width.
If the elements are still too big after shrinking as much as they can, they'll overflow the container.
We can also control the rate at which elements shrink, or stop them from shrinking altogether. We do this via the
flex-shrink
property.
The default value of flex-shrink
is 1. This means "shrink the same amount as all
the other elements". If we don't want an element to shrink at all, we can set flex-shrink: 0
.
If we want an element to shrink at a different rate to the other elements, we can give it a different value of flex-shrink
.
An element with flex-shrink: 2
will shrink twice as fast as elements with the default. So, if the default elements
shrink by 10%, our flex-shrink: 2
element would shrink 20%. An element with flex-shrink: 0.5
would shrink
only 5%. flex-shrink
can take any positive number.
Finally, if you have flex-wrap: wrap
, the elements won't shrink at all. If they don't all fit, they'll just wrap
to another line.
We can also make our elements grow to fill the available space, via the flex-grow
property.
flex-grow
works like flex-shrink
, but in reverse. Like flex-shrink
, it
controls the rate at which the elements grow, so an element's final size will depend on its starting size, the size
of the container, and what else is in the container.
By default, flex-grow
has a value of 0, meaning the element won't grow at all. Setting the value to
any positive number will cause the element to grow. You can also set a max-width
, to cap the size
the element will grow to.
The flex-grow algorithm can be a little tricky to grasp, so let's step through an example.
We'll start with some markup.
<div class="container">
<div class="element"></div>
<div class="element"></div>
<div class="element"></div>
</div>
And some styling.
.container {
display: flex;
width: 400px;
}
.container .element {
flex-basis: 100px;
height: 100px;
}
.container .element:nth-child(1) {
flex-grow: 1;
}
.container .element:nth-child(2) {
flex-grow: 2;
}
.container .element:nth-child(3) {
flex-grow: 3;
}
First, the we're is going to place all of the elements in the flexbox, using their flex-basis values.
Then we're going to calculate how much space is left over in the container (the shaded region below).
The container has a width of 400px, and we have three elements, 100px wide each.
So we've got 100px of free space. We'll remember that for later.
Next we need to work out how many flex-grow "units" we have. The first element has 1, the second has 2, and the third has 3.
We've got a total of 6 flex-grow units.
Then we divide the free space by the number of flex-grow units to calculate the amount of space per flex-grow.
Finally, we distribute the remaining space between the elements, based on how many flex-grow units they need. The first
element has flex-grow: 1
, so it gets 1 * 16px = 16px. The second has flex-grow: 2
, so it gets
2 * 16px = 32px. And the third has flex-grow: 3
, so it gets 3 * 16px = 48px.
Which results in a layout like this.
The first element is 116px wide, the second is 132px and the third is 148px.
Now, in the real world, things are usually a little more complicated than this, as we have to take content, and max-widths and stuff into account. Hopefully though, this is enough to give you an idea of how it works. I recommend playing around with this stuff in the dev tools, and checking out Firefox's flex inspector, for some help visualising what your flex container is doing.
Unlike flex-shrink
, flex-grow
will work with with flex-wrap: wrap
. Elements
will grow to take up all the available space in each line. The grow-rate for each line is determined independently
though, so different lines might grow at different rates.
It's reasonably common to want to set all of flex-grow
, flex-shrink
, and flex-basis
on an element.
Good news: there's a shortcut for that! flex: grow shrink basis
. The default value is 0 1 0
, and both
of the second two values are optional. (You can set just flex-grow
and flex-basis
, but you need to use
a unit on the flex-basis
value, else it will be interpreted as flex-shrink
!)
You can try out the flex
property below, as well as experimenting with how different content affects the final
size of elements. (You can type directly into the bottom part of each element to add content.) You can also resize the container,
using the handle on the bottom right.
The final bit of magic that flex can do is to change the order that elements appear in. As we already mentioned, you can set
flex-direction
to row-reverse
or column-reverse
to invert the order of the line.
You can also set flex-wrap
to wrap-reverse
to reverse the wrapping order. (So in a
flex-direction: row
situation, flex-wrap: wrap-reverse
would cause the second row to appear
above the first one.)
You can also control the placement of individual elements, by setting the order
. Elements will appear in
order corresponding to their order
value. order
can be any integer value, and the default
is 0
. So an element with order: -1
, will appear first in its line.
One word of warning though - when you change the display order like this, the document order remains the same. A screen reader will read the elements in document order, and the tab order will follow the document order. This can lead to a confusing experience for users. As such, I suggest your treat these order-changing properties with extreme caution.
Finally, the question I'm sure is burning everyone's lips: how well supported is display: flex
? The answer is
"very well". All the flex-related properties are 100% supported by all modern browsers, and IE.
However, there are a couple of known bugs in IE that aren't likely to be fixed.
max-width: 100%
on
the container.
flex-basis
value in the flex
shorthand requires a unit, even when it's 0. So you'll
need to do something like flex: 1 0 0%
For a full list of known bugs, refer to the Known Issues tab on the Flexbox page on Can I Use.
If you're looking for any more help with flex, then you should check out the definitive guide: CSS Tricks' Complete Guide to Flexbox.
Grid is perhaps the most useful CSS layout available. It allows us to create dynamic, responsive, two-dimensional layouts. This is not to say that we don't need flexbox anymore though! It's often simpler to setup a flex layout than a grid, and there are some things you can do in flexbox that you just can't manage in grid. Both are useful, and have their place. However, grid does simplify some of the things we used to rely on flexbox for.
Like flexbox, the display: grid
property is applied to the container element, and affects the layout of the children
of the container. Unlike flexbox, the grid exists independent of the elements within it. The grid cells defined in CSS exist
whether they contain any content or not (although they can't be styled, beyond setting their size). You can see this by using
the grid inspector in Firefox. (I highly recommend using Firefox for all your grid-related development needs. The grid inspector
is absolutely invaluable.)
To use the grid inspector in Firefox
You should end up with something like this
Our example here defines a 2x2 grid, using three CSS properties.
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
display: grid
makes our container a grid. grid-template-columns
takes a list of widths. It will create
a column of the given width for each width in the list. In our case, we've got two columns, each 1fr wide.
grid-template-rows
takes a list of heights and creates a row with the given height for each height in the list. So
we've got two rows, each 1fr high. And, altogether, we've got a 2x2 grid, where each cell is 1fr x 1fr.
At this point, you might be wondering what an fr
is. This is a good question. fr
stands for "fraction
of the available space". It works a lot like flex-grow
in flexbox. To calculate the widths of the columns above,
the browser will
So, in our example, each column is going to be 50% of the width of the container.
This example is a little simplistic though. We could also have different sized columns - say
grid-template-columns: 4fr 1fr
. In this case, there are 5frs altogether, so each fr receives 20% of the space
(100% / 5 = 20%). The first column is 4frs wide, so it receives 80% (20% x 4 = 80%). The second column is 1fr, so it just gets 20%.
We can also mix units, like grid-template-columns: 100px 1fr
. In this case, the first column will always be 100px
wide, no matter the size of the container. The second column will take up the remaining space.
So, why do we use frs instead of just sticking with percentages? Basically, the maths is easier. You don't need to use calc()
to mix percentages and fixed units. You can't get the maths wrong and end up with more than 100%. And it makes it super easy to add and remove columns
without needing to recalculate everything, which also works well in responsive designs, where you might have different numbers of
columns on different size screens. There are also a couple of other advantages we'll get to later.
Ok, so this is all well and good, but a grid with nothing in it is pretty boring. So let's see what happens when we add some elements.
You can see the elements get placed along the first row, until that row fills up. Then we start filling up the next row.
And so on until we run out of elements. If we prefer to fill up the columns first, we can do that by setting
grid-auto-flow: column
.
Each element is placed at the start of the cell. In a left-to-right, top-to-bottom script, this is going to be the top left. In a right-to-left script, it would be the top right. And so on.
We can change the vertical positioning of an element within its cell, just like we change the vertical position of an
item in a flexbox - using align-items
or align-self
.
align-items
is applied to the container, and changes the position of all elements in the grid.
align-self
is applied to the item, and just changes the position of that item. align-self
will
override align-items
.
Unlike with flexbox, we can also change the horizontal positioning of an element within its cell.
We do this via justify-items
and justify-self
.
justify-items
is applied to the container, and changes the position of all elements in the grid.
justify-self
is applied to the item, and just changes the position of that item. justify-self
will
override justify-items
.
We can also set the size of the element, relative to its cell. If you set the width or height of a grid item as a percentage, it will be calculated as a percentage of the size of the cell, rather than the parent container (which is how it usually works).
Unlike in flexbox, grid item will shrink down below their min-content size, causing their content to overflow.
To stop this happening, you can set min-width: min-content
.
We can also use percentage-based sizing to create layouts with a bit of breathing room. Just set the element sizes to around 90%, centre-align and justify them, and we get a nice, consistent layout, with a bit of spacing.
This works, but it's not great. If the elements aren't square, then the gaps are different horizontally and vertically. There's also a gap around the elements that's half the size of the gap between the elements, which might be problematic, depending on your design. (Have a look with the grid inspector if you don't know what I mean.)
Fortunately, there is a better way! We can specify how much space we want between elements using the grid-gap
property.
You can set grid-gap
to be a specific measurement, like 10px, or 1em. Or you can use a percentage. The percentage will be
calculated from the width of the entire grid for the column gaps, and the height of the entire grid for the row gaps. You'll get different
spacing for your rows and columns if your grid isn't square. You can also set the row and column gaps differently deliberately by
passing in two values: grid-gap: 10px 20px
. The first value is the gap between the rows. The second value is the gap between the columns.
grid-gap
is another reason to prefer fr over percentages. fr will take the gap into account automatically.
While it's entirely possible to do this with percentages, using calc()
, it's far, far more difficult.
On top of all this, we can also place elements so that they take up more than one cell. We can do this by making them span multiple rows and/or multiple columns. We are limited to rectangular shapes though.
There are a couple of ways we can super-size our elements.
If we want our element to go from column 2, row 3 to column 5, row 4, we can do that by specifying the grid-row
and grid-column
.element {
grid-row: 3 / 5;
grid-column: 2 / 6;
}
The numbers used to specify the start and endpoint actually refer to the lines between the cells. So
grid-column: 1 / 2
will result in an element that goes from the first line (at the edge of the grid), to the
second line. It will only take up one column. You need to calculate your endpoint as your start point + the number of
cells you want to cover.