Statically-Typed Scoped Data Attributes with Vanilla Extract and React

by Ryan Geyer —

Over the last 2 years or so, Waymark has been undergoing an effort to convert our frontend codebase to TypeScript. We have a LOT of code and a fairly small team, so it’s been a gradual process, but we’ve already seen huge benefits for developer experience.

In the process, we have also begun shifting our styles from runtime CSS-in-JS to using Vanilla Extract, a very cool library which allows you to write type-safe and scoped CSS in TypeScript that is then converted to static CSS files at build time. Because the styles are written in TypeScript, this gives us flexibility to dynamically generate CSS, the biggest benefit of runtime CSS-in-JS, but without the performance hit at runtime!

In many places in our codebase, we like to represent state in our HTML using data attributes. Here’s what an example might look like:

// MyComponent.tsx

type Status = "init" | "loading" | "success" | "error";

export default function MyComponent(){
  const [status, setStatus] = useState<Status>("init");

  /* Imagine there's some code here which does something and updates `status` */

  return (
    <div data-status={status}>
      {status}
    </div>
  );
}
/* MyComponent.css */
[data-status="init"], [data-status="loading"] {
  color: gray;
}

[data-status="success"] {
  color: green;
}

[data-status="error"] {
  color: red;
}

This has a few benefits:

  1. It’s a great way to expose internal component state details for unit tests!
  2. Data attributes can make it easier to see what’s going on in your HTML at a glance when debugging, and it’s also very easy to just manually edit the attribute’s value in your dev tools if you need to manually see what a different state looks like.
  3. Representing different possible state values on a single attribute tends to feel cleaner than having to manage multiple unique class names for each possible state value and making sure only one is applied at a time.

However, this approach can come with some downsides:

  1. There are certain data attribute names which you will probably want to reach for a lot, and they can very easily collide if they aren’t scoped properly. For instance, the data-status attribute used in the example above may end up being a very common attribute name that you want to use in multiple different components. This will probably mostly be fine, but the more components that use that attribute name, the more you are opening yourself up to the possibility that one element with a data-status attribute may end up getting wrapped by another element with a data-status attribute and then you may start experiencing some strange unexpected interactions.
  2. By default, attribute names and values are not type-safe. If you misspell an attribute name or value in your HTML or CSS, or if something gets renamed, there’s very little protection to prevent that bug from sneaking by unnoticed! Of course, this is just a typical risk you are usually taking on when working with HTML and CSS, but we’re writing everything in TypeScript so it feels like we should be able to do better!

So, this leads me to the neat little makeDataAttribute util function that I created to solve both of these problems. It allows us to make data attributes both type-safe AND scoped to the components that they’re used in!

How it works

The makeDataAttribute function takes the attribute name as a param and a generic type representing acceptable values for the attribute. It returns a callback which we can then use to apply the data attribute in our styles and components.

const dataStatus = makeDataAtribute<"init" | "loading" | "success" | "error">("data-status");

The returned callback takes an attribute value and returns an object with a scoped version of the attribute name as a key and the provided attribute value as the value. The returned object can be spread in JSX to apply the attribute to an element.

Note that the attribute name will get a small unique hash appended to the end to make sure it doesn’t collide with other attribute names.

// { data-status-asf12: 'init' }
console.log({ ...dataStatus("init") });

This object also has a custom toString() method defined on its prototype so that when the object is used as a string, it will be formatted as a valid CSS selector for the attribute + value combo.

// [data-status-asf12="loading"]
console.log(`${dataStatus("loading")}`);

Here’s what it all looks like in action:

// MyComponent.css.ts

import { style } from '@vanilla-extract/css';
import { makeDataAttribute } from '@libs/shared-ui-styles';

import type { Status } from './MyComponent';

export const dataStatus = makeDataAttribute<Status>("data-status");

export const myComponent = style({
  selectors: {
    [`&:is(${dataStatus("init")}, ${dataStatus("loading")})`]: {
      color: "gray",
    },
    [`&${dataStatus("success")}`]: {
      color: "green",
    },
    [`&${dataStatus("error")}`]: {
      color: "red",
    },
  },
});
// MyComponent.tsx

import { myComponent, dataStatus } from './MyComponent.css';

export type Status = "init" | "loading" | "success" | "error";

export default function MyComponent() {
  const [status, setStatus] = useState<Status>("init");

  /* Insert code that does something to update the status here */

  return (
    <div className={myComponent} {...dataStatus(status)}>
      {status}
    </div>
  );
}

And here’s what the HTML and CSS output from this will look like:

<div className="qsr7lu14" data-status-asf12="init">
  init
</div>
.qsr7lu14:is([data-status-asf12="init"], [data-status-asf12="loading"]) {
  color: gray;
}

.qsr7lu14[data-status-asf12="success"] {
  color: green;
}

.qsr7lu14[data-status-asf12="error"] {
  color: red;
}

I will admit that this API is not perfect; it doesn’t feel perfectly intuitive to me without requiring some explanation, and the fact that there is a meaningful difference between whether you are using the attribute as a string for a selector or as an object to spread on an element in JSX doesn’t feel amazing. I certainly wouldn’t reach for something like this in a smaller project, but in a big complex app like Waymark, I think the benefits from this approach do still outweigh the downsides.

The code

Now that I’ve showcased what it is and how it works, let’s dig into the code!

Note: if you have ideas for how to improve this further let me know. Even better, if want to publish this as an open source library, be my guest!

import { addFunctionSerializer } from '@vanilla-extract/css/functionSerializer';

export function makeDataAttribute<TAttrValue extends string | number | boolean = never>(
  name: string,
) {
  // Add a random 5-digit string to the end of the attribute name to avoid collisions
  const scopedName = `${name}-${Math.random().toString(36).substring(2, 7)}`;

  const dataAttributeValueGetter = _dataAttribute<TAttrValue>(scopedName);

  // Use Vanilla Extract's magical addFunctionSerializer so we can guarantee that
  // the scoped data attribute value getter callback we're creating here
  // is able to be exported from a Vanilla Extract .css.ts file
  // in a way that can be used at runtime
  addFunctionSerializer(dataAttributeValueGetter, {
    importName: '_dataAttribute',
    importPath: 'absolute-import-path/to-this-file',
    args: [[scopedName]],
  });

  return dataAttributeValueGetter;
}

export function _dataAttribute<TAttrValue extends string | number | boolean>(
  attributeName: string,
) {
  const getDataAttributeValue = (attributeValue: TAttrValue): {
    [key: string]: `${TAttrValue}`;
  } => Object.create(Object.prototype, {
    [attributeName]: {
      value: `${attributeValue}`,
      enumerable: true,
    },
    // Custom toString method to return a string representing the data attribute as a CSS selector
    toString: {
      value: () => `[${attributeName}="${attributeValue}"]`,
    },
  });

  // Add an `attributeName` property to the function so we can access it at runtime;
  // this will mainly be useful for debugging/testing purposes
  Object.defineProperty(getDataAttributeValue, 'attributeName', {
    value: attributeName,
  });

  return getDataAttributeValue as typeof getDataAttributeValue & {
    attributeName: string;
  };
}

Okay, there is a lot packed in here! Let’s break it down.

First, we should discuss what exactly Vanilla Extract’s addFunctionSerializer method is, as that is what makes all of this possible.

addFunctionSerializer is a special utility function which is intended only for extremely advanced use cases which usually only matter to library authors. That’s how you know you’re doing something fun!

Because Vanilla Extract is compiled down to CSS at build time, it will normally strip away any functions defined in your .css.ts files. This means that you can’t normally export a function from a .css.ts file and import it into a different file that would use it at runtime, because Vanilla Extract doesn’t know how to serialize that function. However, you may be able to see where this is going… addFunctionSerializer enables you to help tell Vanilla Extract both that a function should be serialized and exactly how to serialize it so that it can be used at runtime!

So, to wrap our heads around what’s going on here, we need to understand that the contents of the makeDataAttribute method in this file will only ever be run at Vanilla Extract’s build-time when generating CSS. So, we need to define and export a second _dataAttribute function which represents the code that we want to be able to be executed at both build-time AND runtime, and then use addFunctionSerializer to point Vanilla Extract in the right direction on how to serialize the callback returned by makeDataAttribute.

Let’s focus on _dataAttribute first.

export function _dataAttribute<TAttrValue extends string | number | boolean>(
  attributeName: string,
) {
  const getDataAttributeValue = (attributeValue: TAttrValue): {
    [key: string]: `${TAttrValue}`;
  } => Object.create(Object.prototype, {
    [attributeName]: {
      value: `${attributeValue}`,
      enumerable: true,
    },
    // Custom toString method to return a string representing the data attribute as a CSS selector
    toString: {
      value: () => `[${attributeName}="${attributeValue}"]`,
    },
  });

  // Add an `attributeName` property to the function so we can access it at runtime;
  // this will mainly be useful for debugging/testing purposes
  Object.defineProperty(getDataAttributeValue, 'attributeName', {
    value: attributeName,
  });

  return getDataAttributeValue as typeof getDataAttributeValue & {
    attributeName: string;
  };
}

So, the main thing this function does is create a getDataAttributeValue callback which takes an attribute value and returns an object that we can use to apply the attribute in our style and component code.

You don’t see Object.create() a whole lot these days, but I opted for that as it provides a good way to be able to control exactly which properties on the object should be enumerable or not. This is important because we want to be able to spread the object on a JSX element’s props without including unwanted junk like our custom toString() method.

Alright, now let’s tackle makeDataAttribute.

const scopedName = `${name}-${Math.random().toString(36).substring(2, 7)}`;

First, we are simply taking the provided data attribute name and appending a random 5-character string to the end to make it unique. This is just using the classic quick and dirty trick of converting a random number to a base-36 string, as this tends to produce pretty decent short unique ID strings.

Now for the weird part.

const dataAttributeValueGetter = _dataAttribute<TAttrValue>(scopedName);

// Use Vanilla Extract's magical addFunctionSerializer so we can guarantee that
// the scoped data attribute value getter callback we're creating here
// is able to be exported from a Vanilla Extract .css.ts file
// in a way that can be used at runtime
addFunctionSerializer(dataAttributeValueGetter, {
  importName: '_dataAttribute',
  importPath: 'absolute-import-path/to-this-file',
  args: [[scopedName]],
});

return dataAttributeValueGetter;

So, ultimately we want makeDataAttribute to return the callback which _dataAttribute creates. In order to make sure this returned callback works at runtime, we’ll then pass it to addFunctionSerializer along with the name and path where Vanilla Extract can import the function that created it, as well as any arguments that said function was called with. Given this information, Vanilla Extract should be able to serialize the callback so we can use it wherever we want!

Conclusions

Alright, so I’ll admit it: this was a lot of complexity and effort for a relatively small win, and even then the final result still has tradeoffs.

That being said, I think this new util has made a postive impact in allowing us to write styles with more confidence, and this was a great exercise for helping build my mental model of how Vanilla Extract works, which has been very helpful as we have continued integrating it into our codebase.

And of course, most importantly of all, this was a ton of fun to build! I loved taking on the challenge of trying to design an API for how type-safe data attributes could even work, and even though I’m not 100% confident that this is the absolute perfect solution, I’m still pretty happy with how it came out.