Using styled-components Without Template Literals
Normally, if you’ve used styled-components
in the past, you’ve probably seen
the default (according to the documentation) way of declaring a component using
the styled
API:
import styled from 'styled-components';
const Button = styled.button`
background: palevioletred;
color: #fff;
`;
Nothing shocking here. The documentation uses this pattern and if you view any
material on styled-components
you’ve probably seen the exact same thing.
Well, in the past few weeks I’ve seen a different pattern in a few libraries
utilizing styled-components
, but I haven't seen much information about it.
Here it is:
const Button = styled('button')([], 'background: palevioletred', 'color: #fff');
This is interesting and I'd like to explore it.
Tagged template literals
If you read Max Stoiber’s post
The magic behind styled-components,
he goes into some detail about the inner workings of his popular CSS library.
The styled
API of styled-components
relies on tagged template literals, and
this is probably the way you will see most people using it.
But, it's not the only way to declare components using the styled
API. This is
the key idea behind this post, yet it may seem unclear right now. So, to answer
the question, how does it work?, we first need to dive a bit further into
tagged template literals.
Let’s make an important distinction regarding template literals vs tagged template literals. What is the difference?
Template literals are, according to Mozilla:
"string literals allowing embedded expressions."
These strings can be multi-line:
const multiLiner = `
Look Ma,
2 lines!
`;
And they can also contain embedded expressions, which is another way of saying they support interpolations:
const food = 'burger';
const str = `Mmmmm! that is a tasty ${food}!`;
${ This here }
is an interpolation. Think of them as placeholders for
JavaScript expressions.
Tagged template literals, on the other hand, are simply template literals that are used to call a function instead of the normal comma-separated values inside of parentheses:
// regular function call
myFunc(1, 2, 3);
// tag function call
myFunc`1, 2, 3`;
The second version of myFunc
above is known as a tag function.
The way the two call sites of myFunc
pass on their parameters is what sets
them apart. You already know how a regular function call passes on its
parameters, but I don’t expect you to know how tag functions do so.
Max sums this up extremely well in his post, and it’s the thing you must understand about tagged template literals, so I will summarize how it works by using the same function he created:
const logArgs = (...args) => console.log(...args);
The function above uses the spread…rest operators. The arguments to the
function are being collected into a single array named args
using the …args
syntax. This is referred to as rest. You can think of it as “collecting the
rest” of the arguments into an array named args
. It’s useful when you don’t
know how many arguments the function might have.
It’s sibling, spread, occurs when we log the arguments to the console using
console.log(...args)
. We’re literally “spreading out” the contents of the
args
array.
These two operators help us visualize exactly what’s being passed to logArgs
.
Let’s examine the result of this function when called in the two ways described
earlier:
logArgs(1, 2, 3);
// -> 1
// -> 2
// -> 3
logArgs`1, 2, 3`;
// -> ["1, 2, 3"]
Calling the function as normal does what we expect. It spreads the args
array
out into individual values, and logs each to the console.
Calling logArgs
using a tagged template literal, on the other hand, logs an
array. This is our first lesson:
Tagged template literals pass an array of string values as the first argument to the tag function.
Things get even more interesting when we include interpolations:
const food = 'burger';
logArgs`Mmmm! That is a tasty ${food}!`;
// -> ["Mmmm! That is a tasty ", "!"]
// -> "burger"
logArgs
still outputs an array of string values as its first argument, but if
the tagged template literal has an interpolation then the expression inside the
interpolation is passed as the next argument.
What happens when there are multiple interpolations?
const food = 'burger';
const adj = 'tasty';
logArgs`Mmmm! That is a ${adj} ${food}!`;
// -> ["Mmmm! That is a ", " ", "!"]
// -> "tasty"
// -> "burger"
We could have as many interpolations as we want, and each one will be passed accordingly. This is the second lesson:
If interpolations exist inside tagged template literals, their containing expressions are passed as additional arguments to the tag function.
Let's see how tagged template literals handle interpolated functions:
logArgs`Mmmm! That is a tasty ${() => 'burger'}`;
// -> ["Mmmm! That is a tasty", "!"]
// -> () => "burger"
The function itself is being included as an argument. This is the essence of
styled-components
. By capturing such a function, the library can execute it
and do what it needs to do, mainly merge the resulting value back in to the
string values inside the array.
Tying our new-found knowledge together
Now that we know how tagged template literals work, let's gain a deeper
understanding of the styled
API:
const Button = styled.button`
background: ${(props) => (props.primary ? 'red' : 'white')};
color: black;
`;
styled.button
is a tag function. If we were to log the arguments of this
function, we'd see this:
logArgs`
background: ${(props) => (props.primary ? 'red' : 'white')};
color: black;
`;
// -> ["background: ", "; color: black;"]
// -> props => props.primary ? "red" : "white"
Are you seeing the power here? It's no wonder why styled-components
has become
so popular as a CSS-in-JS solution. Not only do tagged template literals allow
us to write multi-line CSS naturally, but it allows the library to manipulate
styles through these interpolated functions, giving our components a dynamic
feel.
How does the other pattern work?
Ah, yes. That's why you're here isn't it. Earlier I showed another way of using
the styled
API that I've been seeing lately:
const Button = styled('button')([], 'background: palevioletred', 'color: #fff');
First, understand that styled.button
and styled('button')
are treated the
same way. They're interchangeable.
Second, there's no tagged template literal action here. But, since we know that
styled
supports them, we know how it expects its arguments. This is the
major 🔑.
Remember the two rules:
- Tagged template literals pass an array of string values as the first argument to the tag function.
- If interpolations exist inside tagged template literals, their containing expressions are passed as additional arguments to the tag function.
So the tag function expects an array of string values as its first argument, and interpolated expressions follow suit.
In the pattern above, which I will give the name "The Empty Array Pattern", the arguments are:
// -> []
// -> 'background: palevioletred'
// -> 'color: #fff'
The first argument is an array, and satisfies rule number one. Yes, there's no string values inside the array, but that's totally fine. The additional arguments are strings which, by definition are expressions that produce a value, and as such they satisfy rule number two.
We've mimicked the behavior of tagged template literals without actually using them.
Wrapping up
At the end of the day, both patterns produce the same value. What I'm finding difficult to discover is why you would want to use one over the other. I guess I could see a situation where, using tagged template literals, you had multiple interpolations, and for code readability you could choose the Empty Array Pattern instead:
// as tagged template literal
const Button = styled.button`
background: ${(props) => props.background};
color: ${(props) => props.color};
`;
// as Empty Array Pattern
const Button = styed.button([], (props) => ({
background: props.background,
color: props.color
}));
I'd love to hear insights from others who have experience with these patterns, and what the pros and cons are of each. Reach out to me on Twitter so we can discuss!