Components get harder to maintain as they grow larger. Sometimes it is not obvious how to split a bloated component into smaller components. The code gets more noisy and they become hard to reason about.
In this post, I am going to introduce the idea of "renderless components" which could potentially help you improve your components.
My Amazing Website
We are going to look into the source of the My Amazing Website. (Do not peak into PR yet if you don't want spoilers.)
The Groovy Footer
See that groovy footer at the bottom of the page? Let's have a look at the source for that footer.
<template>
<footer :style="footerStyle">
<div class="text" :style="textStyle">Made with ❤ by Jason Yu © 2019</div>
<label class="insane-mode-label">
<input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
</label>
</footer>
</template>
<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';
const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;
export default {
mounted() {
this.randomFooterStyle();
this.randomTextStyle();
this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
},
beforeDestroy() {
window.clearInterval(this.footerIntervalId);
window.clearInterval(this.textIntervalId);
},
data: () => ({
footerStyle: null,
textStyle: null,
insaneMode: false,
}),
computed: {
insaneFactor() {
return this.insaneMode ? 3 : 1;
},
footerIntervalMs() {
return FOOTER_INTERVAL_MS / this.insaneFactor;
},
textIntervalMs() {
return FOOTER_INTERVAL_MS / this.insaneFactor;
},
},
watch: {
insaneMode() {
window.clearInterval(this.footerIntervalId);
window.clearInterval(this.textIntervalId);
this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
},
},
methods: {
randomFooterStyle() {
const { insaneFactor } = this;
this.footerStyle = {
borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
background: randomColor(),
transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
};
},
randomTextStyle() {
const { insaneFactor } = this;
this.textStyle = {
transform: `rotate(${randomNumber(
-3 * insaneFactor,
3 * insaneFactor,
)}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
color: randomColor(),
transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
};
},
},
};
</script>
<style scoped>
footer {
margin-top: 1rem;
padding: 3rem 0;
transition-property: border-radius, background;
text-align: center;
}
footer .text {
transition-property: color, transform;
}
.insane-mode-label {
display: block;
margin-top: 2rem;
}
</style>
Notice how more than half of the code in <script>
are used to deal with window.setInterval
and window.clearInterval
. How could we simplify this component? It doesn't make sense to move the footer text and the background into their own components, because they semantically belong to footer not on their own!
<Interval>
Let's create a component called <Interval>
which would handle everything related to window.setInterval
and window.clearInterval
for us.
src/components/renderless/Interval.js:
export default {
render: () => null,
};
First of all, as the title of this article suggests, the render
function should render nothing. So we return null
.
Props
Next, what sort of props should <Interval>
accepts? Clearly we wish to be able to control the delay
between each interval.
src/components/renderless/Interval.js:
export default {
props: {
delay: {
type: Number,
required: true,
},
},
render: () => null,
}
Mounted
When the <Interval>
is mounted, we expect it to start the interval and would tear the interval off at beforeDestroyed
.
src/components/renderless/Interval.js:
export default {
props: {
delay: {
type: Number,
required: true,
},
},
mounted () {
this.id = window.setInterval(() => /* ... */, this.delay);
},
beforeDestroy () {
window.clearInterval(this.id);
},
render: () => null,
}
What should we do in /* ... */
?
setInterval
takes in two arguments, a callback and a delay. So should we take in the callback
as a prop? That's a great idea and could work nicely. But I would say a more "Vue-ish" way is to emit events!
src/components/renderless/Interval.js:
export default {
props: {
delay: {
type: Number,
required: true,
},
},
mounted () {
this.id = window.setInterval(() => this.$emit('tick'), this.delay);
},
beforeDestroy () {
window.clearInterval(this.id);
},
render: () => null,
}
DONE!
As simple as it is, it empowers us with the power of interval without needing to manage interval ids and the setup/tear-down of the interval!
Refactor Footer.vue!
Let's handle the setInterval
and clearInterval
in the mounted
and beforeDestroy
hooks respectively in Footer.vue:
// ...
mounted() {
// ...
this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
},
beforeDestroy() {
window.clearInterval(this.footerIntervalId);
window.clearInterval(this.textIntervalId);
},
// ...
The code above can now be replaced by:
<Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
<Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
The resulting Footer.vue will look like:
<template>
<footer :style="footerStyle">
<Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
<Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
<div class="text" :style="textStyle">Made with ❤ by Jason Yu © 2019</div>
<label class="insane-mode-label">
<input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
</label>
</footer>
</template>
<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';
import Interval from './renderless/Interval';
const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;
export default {
mounted() {
this.randomFooterStyle();
this.randomTextStyle();
},
data: () => ({
footerStyle: null,
textStyle: null,
insaneMode: false,
}),
computed: {
insaneFactor() {
return this.insaneMode ? 3 : 1;
},
footerIntervalMs() {
return FOOTER_INTERVAL_MS / this.insaneFactor;
},
textIntervalMs() {
return FOOTER_INTERVAL_MS / this.insaneFactor;
},
},
watch: {
insaneMode() {
window.clearInterval(this.footerIntervalId);
window.clearInterval(this.textIntervalId);
this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
},
},
methods: {
randomFooterStyle() {
const { insaneFactor } = this;
this.footerStyle = {
borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
background: randomColor(),
transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
};
},
randomTextStyle() {
const { insaneFactor } = this;
this.textStyle = {
transform: `rotate(${randomNumber(
-3 * insaneFactor,
3 * insaneFactor,
)}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
color: randomColor(),
transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
};
},
},
};
</script>
<style scoped>
footer {
margin-top: 1rem;
padding: 3rem 0;
transition-property: border-radius, background;
text-align: center;
}
footer .text {
transition-property: color, transform;
}
.insane-mode-label {
display: block;
margin-top: 2rem;
}
</style>
Notice how much nicer already the component looks? No more ridiculous names like footerIntervalId
or textIntervalId
and no need to worry any more about forgetting to tear intervals off!
Insane Mode
The insane mode is powered by the watcher in Footer.vue:
<template>
<!-- ... -->
<Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
<Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
<!-- ... -->
</template>
<script>
// ...
watch: {
insaneMode() {
window.clearInterval(this.footerIntervalId);
window.clearInterval(this.textIntervalId);
this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
},
},
// ...
</script>
We would obviously like to remove this watcher and move the logic inside <Interval>
.
When the insane mode is triggered, the <Interval>
receives a new delay
prop since this.footerIntervalMs
and this.textIntervalMs
are changed. However, <Interval>
has not yet been programmed to react to the change of delay
. We can add a watcher to delay
which will tear down the existing interval and set up a new one.
Interval.js
export default {
props: {
delay: {
type: Number,
required: true,
},
},
mounted () {
this.id = window.setInterval(() => this.$emit('tick'), this.delay);
},
beforeDestroy () {
window.clearInterval(this.id);
},
watch: {
delay () {
window.clearInterval(this.id);
this.id = window.setInterval(() => this.$emit('tick'), this.delay);
},
},
render: () => null,
}
Now we could remove the watcher in Footer.vue:
watch: {
insaneMode() {
window.clearInterval(this.footerIntervalId);
window.clearInterval(this.textIntervalId);
this.footerIntervalId = window.setInterval(this.randomFooterStyle, this.footerIntervalMs);
this.textIntervalId = window.setInterval(this.randomTextStyle, this.textIntervalMs);
},
},
The final Footer.vue looks like this:
<template>
<footer :style="footerStyle">
<Interval :delay="footerIntervalMs" @tick="randomFooterStyle"></Interval>
<Interval :delay="textIntervalMs" @tick="randomTextStyle"></Interval>
<div class="text" :style="textStyle">Made with ❤ by Jason Yu © 2019</div>
<label class="insane-mode-label">
<input type="checkbox" v-model="insaneMode"> Insane Mode (new!)
</label>
</footer>
</template>
<script>
import { randomNumber, randomPercentage, randomColor } from '../services/random';
import Interval from './renderless/Interval';
const FOOTER_INTERVAL_MS = 543;
const TEXT_INTERVAL_MS = FOOTER_INTERVAL_MS / 3;
export default {
mounted() {
this.randomFooterStyle();
this.randomTextStyle();
},
data: () => ({
footerStyle: null,
textStyle: null,
insaneMode: false,
}),
computed: {
insaneFactor() {
return this.insaneMode ? 3 : 1;
},
footerIntervalMs() {
return FOOTER_INTERVAL_MS / this.insaneFactor;
},
textIntervalMs() {
return FOOTER_INTERVAL_MS / this.insaneFactor;
},
},
methods: {
randomFooterStyle() {
const { insaneFactor } = this;
this.footerStyle = {
borderRadius: `${randomPercentage()} ${randomPercentage()} / ${randomPercentage()} ${randomPercentage()}`,
background: randomColor(),
transitionDuration: `${FOOTER_INTERVAL_MS / insaneFactor}ms`,
};
},
randomTextStyle() {
const { insaneFactor } = this;
this.textStyle = {
transform: `rotate(${randomNumber(
-3 * insaneFactor,
3 * insaneFactor,
)}deg) scale(${randomNumber(0.7 * insaneFactor, 1.3 * insaneFactor)})`,
color: randomColor(),
transitionDuration: `${TEXT_INTERVAL_MS / insaneFactor}ms`,
};
},
},
};
</script>
<style scoped>
footer {
margin-top: 1rem;
padding: 3rem 0;
transition-property: border-radius, background;
text-align: center;
}
footer .text {
transition-property: color, transform;
}
.insane-mode-label {
display: block;
margin-top: 2rem;
}
</style>
Challenge for you!
I hope you find this article interesting. If you wish to learn more about different types of renderless components, please watch the video of the talk that I gave with more live-coding examples.
There are still two lines in the mounted
hook in Footer.vue. Could you think of a way to extend <Interval>
so that we could eliminate the whole of the mounted
hook? Peek the PR here to get ideas.
mounted() {
this.randomFooterStyle();
this.randomTextStyle();
},
Why?
We build really cool product at Attest with Vue. And we find this pattern beneficial in many ways, e.g. maintainability, correctness, testability etc. If you would like to join this exceptionally talented team, apply here today!
P.S. We love the function-based RFC.