Controlled Props Pattern

Have you ever had a component where you needed to override its internal state?

For example, you have an accordion that keeps track of its own open state internally.

But there are a few instances where you want to be able to override that internal state and force it open from the parent component.

This is a tricky but pretty common scenario.

The Controlled Props Pattern is designed for exactly this situation.

Controlled Props

We'll start by building a simple component that will show and hide its content when clicked.

<template>
<div>
<div
class="title"
@click="toggleHidden"
>
{{ title }}
</div>
<div
class="content"
v-if="!hidden"
>
<slot />
</div>
</div>
</template>
import { ref } from "vue";
const props = defineProps({
title: String,
});
const hidden = ref(true);
const toggleHidden = () => {
hidden.value = !hidden.value;
};

If you’re using the Options API instead, it would look like this:

export default {
name: 'Toggle',
props: {
title: {
type: String,
},
},
data() {
return {
hidden: true,
};
},
methods: {
toggleHidden() {
this.hidden = !this.hidden;
},
},
};

We just need to supply some content for the component in order to use it:

<template>
<Toggle title="Toggled Content">
This content can be toggled on and off.
</Toggle>
</template>

Because the component keeps track of its own state, the parent component doesn't have to do anything.

Give me more control!

This content is normally hidden until the user clicks on the title.

But now our requirements have changed, and now we want to show the content when something else in the app happens. We want the parent to be able to trigger the content to be visible.

In other words, we no longer want the component to manage the open state. We want the parent to manage the hidden state of the toggle.

Refactoring hidden to be a prop instead is fairly straightforward, so we'll do that right away. We’ll also need to define an event and emit it when we want to toggle the value:

import { ref } from "vue";
const props = defineProps({
title: String,
hidden: Boolean,
});
const emit = defineEmits(['click']);
const toggleHidden = () => emit('click');

In the Options API we’d have this:

export default {
name: 'Toggle',
emits: ['click'],
props: {
title: {
type: String,
required: true,
},
hidden: {
type: Boolean,
default: true,
},
},
methods: {
toggleHidden() {
this.$emit('click');
},
},
};

Using the toggle is now a little more work, but we have full control over it from the parent:

<template>
<Toggle
title="Toggled Content"
:hidden="hidden"
@click="hidden = !hidden"
>
This content can be toggled on and off.
</Toggle>
</template>

In this parent component we can now update hidden whenever we want, and force the toggle component to show the content.

Can we have it both ways?

We've now seen two different ways of writing this component.

The first way is the easiest to use, because the toggle component keeps track of its own state. The second way gives us more control, because it's the parent that has to manage and maintain the state for the toggle.

But can we have both?

Is there a way to have one component that can be used in both ways?

Well, that's what we're going to tackle right now 😉

The big problem we have is that we want to have a hidden prop as well as a hidden state variable.

We'll have to use different names for these, so we'll call the state _hidden so the prop will still have a nice name:

<template>
<div>
<div
class="title"
@click="toggleHidden"
>
{{ title }}
</div>
<div
class="content"
v-if="!hidden"
>
<slot />
</div>
</div>
</template>
import { ref } from "vue";
const props = defineProps({
title: String,
hidden: Boolean,
});
const emit = defineEmits(['click']);
const _hidden = ref(true);
const toggleHidden = () => emit('click');

In the Options API instead:

export default {
name: 'Toggle',
emits: ['click'],
props: {
title: {
type: String,
},
hidden: {
type: Boolean,
}
},
data() {
return {
_hidden: true,
};
},
methods: {
toggleHidden() {
this.$emit('click');
},
},
};

Notice that we don’t provide a default value for the hidden prop, rather than set it to true or false.

This is necessary because we'll be using a computed ref to "combine" the prop and internal state together:

const hidden = computed(() =>
props.hidden !== undefined
? props.hidden
: _hidden.value
);

Using the Options API we’d need to set up a computed prop, but here we have to use a $ prefix to avoid naming clashes between the prop that’s already named hidden:

computed: {
$hidden() {
return this.hidden !== undefined
? this.hidden
: this._hidden;
},
},

Note: If you’re using the Options API you’d need to update your template to use this $hidden value instead of the hidden computed ref.

If the prop is set, we'll use that. Otherwise we'll use the internal state.

This is what we have so far:

<template>
<div>
<div
class="title"
@click="toggleHidden"
>
{{ title }}
</div>
<div
class="content"
v-if="!hidden"
>
<slot />
</div>
</div>
</template>
import { ref, computed } from "vue";
const props = defineProps({
title: String,
hidden: Boolean,
});
const emit = defineEmits(['click']);
const _hidden = ref(false);
const toggleHidden = () => emit('click');
const hidden = computed(() =>
props.hidden !== undefined ? props.hidden : _hidden.value
);

Cool cool cool.

The Options API version of this would be this (notice that we use $hidden in the template):

<template>
<div>
<div
class="title"
@click="toggleHidden"
>
{{ title }}
</div>
<div
class="content"
v-if="!$hidden"
>
<slot />
</div>
</div>
</template>
export default {
name: 'Toggle',
emits: ['click'],
props: {
title: {
type: String,
},
hidden: {
type: Boolean,
}
},
data() {
return {
_hidden: true,
};
},
methods: {
toggleHidden() {
this.$emit('click');
},
},
computed: {
$hidden() {
return this.hidden !== undefined
? this.hidden
: this._hidden;
},
},
};

Now we have it set up so that our hidden prop will override the internal state if it's set.

But now there's an issue with the toggleHidden method. Just like the computed ref (or computed prop), we need it to switch between what it modifies depending on whether or not the hidden prop is set.

An easy way to do that is to drop an if statement in that method:

const toggleHidden = () => {
if (props.hidden !== undefined) {
emit('click');
else {
_hidden.value = !_hidden.value;
}
};

The Options API computed prop looks nearly identical:

toggleHidden() {
if (this.hidden !== undefined) {
this.$emit('click');
} else {
this._hidden = !this._hidden;
}
},

If you want to be fancy and take it up a level, you can also use computed setters for this too. Bonus points if you already knew about computed setters, but don't worry if you didn't. I don't think I've ever used them.

Now we've achieved our dream: a component that can use its internal state but also be overridden by a parents state!

You can take this method and apply it to as many things in your component as you want.

There are lots of cases where it's nice to have some properties that are controlled like this while the rest of the component acts "normally".

You can see a working demo of this here.

Here’s the final code for the Composition API version:

<template>
<div>
<div
class="title"
@click="toggleHidden"
>
{{ title }}
</div>
<div
class="content"
v-if="!hidden"
>
<slot />
</div>
</div>
</template>
import { ref, computed } from "vue";
const props = defineProps({
title: String,
hidden: Boolean,
});
const emit = defineEmits(["click"]);
// 1. Define the internal state
const _hidden = ref(true);
// 2. The component has control over its internal state *and* parent state
const toggleHidden = () => {
if (props.hidden !== undefined) {
emit("click");
} else {
_hidden.value = !_hidden.value;
}
};
// 3. The state we actually use is overridden by the prop if it is set
const hidden = computed(() =>
props.hidden !== undefined ? props.hidden : _hidden.value
);

Here’s the final Options API version:

<template>
<div>
<div
class="title"
@click="toggleHidden"
>
{{ title }}
</div>
<div
class="content"
v-if="!$hidden"
>
<slot />
</div>
</div>
</template>
export default {
name: 'Toggle',
emits: ['click'],
props: {
title: {
type: String,
},
hidden: {
type: Boolean,
}
},
data() {
return {
_hidden: true,
};
},
methods: {
toggleHidden() {
if (this.hidden !== undefined) {
this.$emit('click');
} else {
this._hidden = !this._hidden;
}
},
},
computed: {
$hidden() {
return this.hidden !== undefined
? this.hidden
: this._hidden;
},
},
};