Stateful Functional Components
We often describe functional components as stateless and instanceless. As they donāt manage their own state nor watch any reactive data for changes. They are often reliant on their props. Here I explore how to introduce state into functional components and why it might be useful.
Functional components are often overlooked. They hardly have anything useful compared to regular components. They have no state (reactive data) and they cannot manage their own. They also donāt have any methods and they donāt have event cycles nor able to watch other reactive data.
Yet they are more performant than their stateful counterparts. They are usually much smaller and never get to become a monster component. As they usually used as a UI bling. For example, colored labels, icons, links or static UI layouts. Since they are cheap to render you can use them as a component to group some of the layout, like a footer.
I would like to point out some distinction between functional components. Some are āpureā as their rendered output only depends on the props they receive. Very much like a āpureā function. Here is an example for a pure functional component that renders the user name:
jsexport default {
name: 'UserCard',
functional: true,
props: {
name: {
type: String,
required: true,
},
},
render(h, { props }) {
return h('span', props.name);
},
};
The other type is āimpureā functional components. They cheat by relying on reactive state from external sources, like their parent. For example this is the same as the last one but is impure:
jsexport default {
name: 'UserCard',
functional: true,
render(h, { parent }) {
return h('div', parent.$store.user.name);
},
};
Guess which one is more brittle in a production app? You are right, the impure functional component is tightly coupled to its direct parent. If for whatever reason the parent doesnāt have $store
available, it will break. But being able to piggy back on the parent allows us to create cheap yet kinda stateful components. For example if you use vue-i18n
and you want to render a localized text in a functional component, like a simple āsubmitā button.
jsexport default {
name: 'SubmitButton',
functional: true,
render(h, { parent }) {
return h('button', parent.$i18n.$t('submit'));
},
};
This is not evil, itās fine as long as you guarantee $i18n
being there on every parent for that component.
To me, designing components is not about whether they will do the job or not.
Of course they will do the job, they have to.
After drafting a working component, I always check if I can make it functional, not because some negligible performance gain, but because functional components introduce discipline to your design.
By limiting the amount of ways your component can communicate with the outside world, it will be much cleaner and will not grow to be a monster component like I pointed out earlier. They can provide a good indication if a component should be split into smaller functional ones. And when you finally give up and allow to them to be stateful you end up with a much elegant component.
Stateful components invite laziness into deciding how many props should a component receive, how many reactive data does it have. With functional ones, you struggle to add anything to it, because it has to fit. Like I said, discipline.
Donāt get me wrong, I always end up having much more regular components than functional ones, thatās to be expected.
An overlooked advantage
Functional components have an often overlooked advantage that I highly value. The ability to render multiple root nodes is too powerful, especially coupled with scoped slots and renderless components.
For example this does not render anything but its scope:
jsexport default {
name: 'Time',
functional: true,
render(_, { scopedSlots }) {
return scopedSlots.default({ time: Date.now() });
},
};
I know itās not very useful but this component can be used like this:
vue<time v-slot="{ time }">
<span>{{ time }}</span>
<span>{{ time + 1000 }}</span>
</time>
And it would only render the two span
s without any additional markup. Functional components are much easier to be renderless than stateful components because the latter cannot render multiple root nodes, so they always need some kind of wrapper.
The Middle-ground
Remember how $store.user.name
would render correctly in your functional component? By correctly, I mean when the store state changes the component will re-render as well to display the new value, this means the render functions track their reactive dependencies regardless if the rendered component is functional or not.
Now letās say we want to create a component that provides the location of the mouse pointer to their slot, assuming we have multiple of these, would each of them have its own state even though the mouse position is the same across all components? Also how would we make them functional yet make the pointer coordinates reactive?
Lets create a working component first then worry about it being functional or not, this is a basic implementation:
vue<template>
<div>
<slot :x="x" :y="y"></slot>
</div>
</template>
<script>
export default {
name: 'MousePosition',
data: () => ({
x: null,
y: null,
}),
mounted() {
// Add our listener.
document.addEventListener('mousemove', (e) => {
this.updatePosition(e);
});
},
methods: {
updatePosition(e) {
this.x = e.pageX;
this.y = e.pageY;
},
},
};
</script>
Here it is in action:
Very cool, but some immediate issues come up:
- first it is not a renderless component as it renders an outer div.
- secondly if we were to use too many of these, we would have too many renders and state updates.
we can remedy that by using the same listener for all the components but you would then to maintain the āsubscribedā components to such event, which is annoying. Only if we had some sort of a ātune in when interestedā component type, maybe if we had a certain API to solve this problem š.
Back to the topic, how would we make this component functional while fixing the issues we pointed out?
Remember the rules! We donāt want to cheat by using Vuex or the component parent
, they have to be self-contained yet stateful and functional as well.
There is a little function introduced in Vue 2.6 called observable
which allows you to create reactive values without a Vue instance. I call them ādetached reactive valuesā, mouthful maybe but it sounds smart.
In theory we should be able to create some local state for our functional components with Vue.observable
and manage that state with some functions.
Letās begin charting our components, first we will use Vue.observable
to create our state:
jsimport Vue from 'vue';
// create reactive state.
const state = Vue.observable({
x: null,
y: null,
});
Our update function should simple:
jsimport Vue from 'vue';
// create reactive state.
const state = Vue.observable({
x: null,
y: null,
});
// Handle position updates.
const updatePosition = (e) => {
state.x = e.pageX;
state.y = e.pageY;
};
Okay, last remaining thing is to render the component and listen for the mouse event:
jsexport const MousePosition = {
functional: true,
render(_, { scopedSlots }) {
document.addEventListener('mousemove', updatePosition); // oopsie!
// render the scoped slot.
return scopedSlots.default(state);
},
};
Well, it works. But thatās a yikes from me. Because we had no mounted
hook, we added our listener in a rather dangerous spot if our component were to be slightly complex we might end up with too many listeners or infinite render-loops. Letās fix that real quick:
jsdocument.addEventListener('mousemove', updatePosition);
export const MousePosition = {
functional: true,
render(_, { scopedSlots }) {
// render the scoped slot.
return scopedSlots.default(state);
},
};
This is much better, only one listener is added for all the MousePosition
components and they all use the same state object. Whatās neat is that our components do not actually mutate the mouse position state, they only read it. And our listener is the one that mutates it, very clean if I might say.
Letās take it a notch further and only listen when the component is used at least once:
jsimport Vue from 'vue';
// create reactive state.
const state = Vue.observable({
x: null,
y: null,
});
// Handle position updates.
const updatePosition = (e) => {
state.x = e.pageX;
state.y = e.pageY;
};
let isDoneSetup = false;
export const MousePosition = {
name: 'MousePosition',
functional: true,
render(_, { scopedSlots }) {
if (!isDoneSetup) {
document.addEventListener('mousemove', updatePosition);
isDoneSetup = true;
}
// render the scoped slot.
return scopedSlots.default(state);
},
};
Check it out in action:
Wait,
isDoneSetup
sounds familiar?
So letās check:
- Is our component functional? Check ā
- Is it loosely coupled to its parent? Check ā
- Is it dependent on its scope/props? Check ā
Now is it pure? Well technically its not, since it does not receive any props and the state is external to it. But if we think of pure as in an isolated blackbox, then yes, it is not brittle like āimpureā components and it is self contained.
Conclusion
We managed to combine the best of the two worlds, our hybrid component uses state in a completely isolated and safe manner. This approach seems to fit well with āproviderā components, usually something centralized or global like a mouse position or GPS coordinates.
Thanks for reading! š