Context-Aware Props in Vue.js Components

  You block advertising 😢
Would you like to buy me a ☕️ instead?

Recently I saw an interesting Tweet by Mark Dalgleish, about the idea of contextual defaults for React components. I was especially interested in this because I had to solve a similar problem only a few days before.

The Context-Aware Component Pattern

Table of Contents

Basic Concept

In the following screenshot, you can see two buttons: a dark button on a white background and a light button on a black background.

A dark button on a light background and a light button on a dark background.

The style of the buttons depends on their context

<template>
  <BaseIsland background-color="white">
    <BaseButton>
      Dark Button
    </BaseButton>
  </BaseIsland>
  <BaseIsland background-color="black">
    <BaseButton>
      Light Button
    </BaseButton>
  </BaseIsland>
</template>

You can see that both buttons are initialized the same way, without any modifier properties. This means that the buttons are context-aware and change their looks according to the context (BaseIsland) instead of us having to set a property explicitly.

Pure CSS Solution

If you are an old school CSS ninja, you immediately know how we can utilize nesting to solve this problem.

.c-button {
  // ...
  background-color: #444;
  color: #fff;
}

.s-background-dark .c-button {
  background-color: #fff;
  color: #444;
}

But there is a reason why we mostly avoid to write nested styles. It requires a lot of discipline to keep our CSS sane in the long run if we liberally rely on nesting.

The Context-Aware Component Pattern

In one of my earlier articles, I wrote about how to replicate React Context in Vue.js. Let’s take a look at how we can use this pattern to provide context-aware default properties for our BaseButton component.

<!-- src/components/ProvideBackgroundColor.vue -->
<template>
  <slot/>
</template>

<script>
import { computed, provide } from 'vue';

export const BackgroundColorProviderSymbol = Symbol('Background color provider identifier');

// For simplicity we define those constants in here. In a real
// application those would probably come from a global configuration.
const COLORS_DARK = ['black', 'darkGray'];
const COLORS_LIGHT = ['white', 'lightGray'];
const COLORS = [...COLORS_DARK, ...COLORS_LIGHT];

export const TONE = {
  dark: 'dark',
  light: 'light',
};

export default {
  props: {
    backgroundColor: {
      default: 'white',
      type: String,
      // Check if the given color is valid.
      validator(value) {
        return COLORS.includes(value);
      },
    },
  },
  setup(props) {
    // Make sure the `backgroundColor` we provide is reactive.
    let backgroundColor = computed(() => props.backgroundColor);
    // We can have unlimited background colors but only two tones.
    // But depending on your use-case, there can also be more tones.
    let backgroundTone = computed(() => {
      let isDarkTone = COLORS_DARK.includes(props.backgroundColor);
      return isDarkTone ? TONE.dark : TONE.light;
    });

    provide(BackgroundColorProviderSymbol, { backgroundColor, backgroundTone });
  },
};
</script>

The provider component above takes a backgroundColor property and, depending on its value, decides if the backgroundTone of the background color is dark or light. Child components can now get the information in which background context they are rendered and change their look or even their behavior accordingly.

<!-- src/components/BaseIsland.vue -->
<template>
  <ProvideBackgroundColor :background-color="backgroundColor">
    <div :class="`bg-${backgroundColor} p-${padding}`">
      <slot/>
    </div>
  </ProvideBackgroundColor>
</template>

<script>
import ProvideBackgroundColor from './ProvideBackgroundColor.vue';

export default {
  components: {
    ProvideBackgroundColor,
  },
  props: {
    backgroundColor: {
      default: 'white',
      type: String,
    },
    padding: {
      default: '12',
      type: String,
    },
  },
};
</script>

<style>
.bg-white {
  background-color: white;
}

.bg-black {
  background-color: black;
}

/* ... */
</style>

The BaseIsland component above is a simple abstraction for some boxed-off content with some padding around it and a background color. We use the ProvideBackgroundColor component to inform all child components about the background color of one of their parent components.


Do you want to learn more about advanced Vue.js techniques?

Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.


Next, we want to create a context-aware BaseButton component. To make this easier for us to do, we first create a component factory function that serves as an abstraction to make components context-aware quickly.

// src/utils/context-aware-component-factory.js
import { h, inject, unref } from 'vue';

export function contextAwareComponentFactory(component) {
  let contextAwareProps = {};
  let contextAwareComponent = (props, { attrs, slots }) => {
    // Make it possible to use the context-aware component as the default
    // export. The Vue SFC compiler appends the render function generated from
    // the <template> section to the object exported as the default export. In
    // this case, it is appended to `contextAwareComponent`.
    if (contextAwareComponent.render) {
      component.render = contextAwareComponent.render;
    }

    let options = { ...attrs };

    for (let [propName, propOptions] of Object.entries(component.props)) {
      let isContextAware = Boolean(propOptions.context);
      if (!isContextAware) continue;

      let contextId = propOptions.context.id || propOptions.context;
      let context = inject(contextId, null);
      let hasContext = Boolean(context);
      if (!hasContext) continue;

      let fallbackResolver = () => unref(context[propName]);
      let resolveContextValue = propOptions.context.adapter || fallbackResolver;

      // Do not override explicitly set props.
      options[propName] = props[propName] || resolveContextValue(context);
      contextAwareProps[propName] = propOptions;
    }

    return h(component, options, slots);
  };

  contextAwareComponent.props = contextAwareProps;

  return contextAwareComponent;
}

The contextAwareComponentFactory() above takes a component, and checks its props option for props with a context property. It returns a new component, which renders the given component with pre-set values for the given context-aware props.

<!-- src/components/BaseButton.vue -->
<template>
  <button :class="[backgroundClass, textColorClass]">
    <slot/>
  </button>
</template>

<script>
import { computed, unref } from 'vue';

import { contextAwareComponentFactory } from '../utils/context-aware-component-factory';
import { TONE, BackgroundProviderSymbol } from './ProvideBackground.vue';

export default contextAwareComponentFactory({
  name: `BaseButton`,
  props: {
    tone: {
      context: {
        // If the background tone is `dark`, the button
        // tone must be `light` and vice versa.
        adapter: context => (unref(context.backgroundTone) === TONE.dark ? TONE.light : TONE.dark),
        id: BackgroundProviderSymbol,
      },
      default: TONE.dark,
      type: String,
    },
  },
  setup(props) {
    let backgroundClass = computed(() => (props.tone === TONE.dark ? `bg-black` : `bg-white`));
    let textColorClass = computed(() => (props.tone === TONE.dark ? `text-white` : `text-black`));

    return {
      backgroundClass,
      textColorClass,
    };
  },
});
</script>

<style>
.bg-black {
  background-color: black;
}

.bg-white {
  background-color: white;
}

/* ... */
</style>

In BaseButton.vue, we export a context-aware variant of our button component. We can see that we use an adapter function to set the tone property according to its context. If we only need a 1:1 mapping between context and component prop, we can simply configure the context as context: BackgroundProviderSymbol. But in this case, the button’s tone needs to be the exact opposite of the context’s tone.

<template>
  <BaseIsland background-color="black">
    <BaseButton tone="light">
      Manual set tone
    </BaseButton>
    <BaseButton>
      Context-Aware Button
    </BaseButton>
  </BaseIsland>
</template>

<script>
import BaseButton from './BaseButton.vue';
// ...
</script>

The first instance of BaseButton in the example above has its tone property manually set. While the first BaseButton’s tone is static, the second button will automatically adapt to the background color of the parent BaseIsland component.

Wrapping It Up

Although this is a potent pattern, it suffers from some of the same problems as nested CSS: it is not always immediately apparent where individual styles come from. On the other hand, this pattern is even more powerful because we can modify the looks of a component and its functionality based on its context. If you decide to use a similar approach in your codebase, be aware of its potential downsides and make sure that everybody who works with the codebase is on the same page.


Do you want to learn how to build advanced Vue.js applications?

Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.



Do you enjoy reading my blog?

You can buy me a ☕️ on Ko-fi!

☕️ Support Me on Ko-fi