Type-Safe Eleventy Data Cascade Access with TSX and Preact Hooks
Tags: eleventy, 11ty, typescript, preact
Published: 2024-05-19 17:32:12

*This post continues where [Making Eleventy Data Traceable with TSX and Zod](https://dev.to/kgf/making-eleventy-data-traceable-with-tsx-and-zod-3216) leaves off; it will be easier to follow after completing the steps in that post first.*

My previous post culminated in having fully-typed (and validated) data available to top-level templates in TSX; from there, it is possible to build an entire component hierarchy, passing both Eleventy-supplied and custom data throughout.

This can turn out to be more cumbersome the more you componentize, as you may end up needing to pass properties down repeatedly, even in the case of Eleventy-supplied data.

What if we could access and validate data needed directly within each component rather than all-at-once at the top level? This would be more in line with how Eleventy templates are typically written, but with the same typing and validation assurances of the previous approach.

Since we've already started down the path of TSX, this sounds like something that context could address...

## Replacing jsx-async-runtime with Preact

`jsx-async-runtime` doesn't know anything about the concept of context, as that is a feature specific to React and Preact. We'll use the latter, since it's far more lightweight. Bear in mind that we're only using this within the Eleventy build process itself; it will not add any client-side JavaScript.

First, let's update dependencies:

```
npm rm jsx-async-runtime
npm i preact preact-render-to-string
# (or yarn add/remove, etc. as desired in place of npm)
```

Update `tsconfig.json` to reference the correct JSX runtime:

```
"jsxImportSource": "preact",
```

Then update the `tsx` transform in `eleventy.config.ts`:

```ts
import type { VNode } from "preact";
import { renderToString } from "preact-render-to-string";

// ...

eleventyConfig.addTransform("tsx", (content: VNode) =>
  renderToString(content)
);
```

The `content` property we defined in `EleventyProps` in the previous post also needs updating:

```ts
import type { ComponentChildren } from "preact";

// ...

export interface EleventyProps {
  content: ComponentChildren;
  eleventy: EleventyMeta;
  page: EleventyPage;
}
```

### Handling TSX vs. non-TSX Content

In the previous post, I mentioned that `content` from both TSX and non-TSX templates would Just Work with `jsx-async-runtime` because it doesn't escape HTML in JavaScript by default.

Preact _does_ escape by default, which means that while TSX template content (received as JSX children) can be handled as before...

```tsx
  <body>{content}</body>
```

...non-TSX template content (received as strings) needs to remain unescaped, since it is expected to contain markup:

```tsx
<body dangerouslySetInnerHTML={{ __html: content }} />
```

One layout can support both TSX and non-TSX templates by checking whether `content` is a string:

```tsx
typeof content === "string" ? (
  <body dangerouslySetInnerHTML={{ __html: content }} />
) : (
  <body>{content}</body>
)
```

If you find you need to support both formats across many layouts, you can create a helper function like this:

```tsx
import { type ComponentChildren } from "preact";

export const resolveContent = (content: ComponentChildren) =>
  typeof content === "string"
    ? { dangerouslySetInnerHTML: { __html: content } }
    : { children: content };

// Example usage:
// <body {...resolveContent(content)} />
```

If you're also planning to use both TSX and non-TSX _layouts_, you'll  find that the `tsx` transform needs to be made more robust than it was in the original JetBrains series, to avoid running `renderToString` on non-TSX layouts (because, like above, `content` will already be a string):

```ts
eleventyConfig.addTransform("tsx", (content: string | VNode) =>
  typeof content === "string" ? content : renderToString(content)
);
```

## Creating the Context Object

Creating a context requires setting up a default value for it. Rather than write a bunch of lines for a value that should never be used, we're going to take an intentional shortcut here:

```ts
const DataContext =
  createContext(null as unknown as EleventyProps);
```

This cast ensures that the type returned when consuming the context is always `EleventyProps`; meanwhile, the default `null` value will cause immediate and loud failures in the build process if we ever forget to hook up the provider.

## Provider Setup

We can save ourselves some work - and make it as easy as possible to remember to hook up the provider - by creating a helper for TSX templates' `render` functions:

```tsx
import { type ComponentType } from "preact";

// ...

export const createRender =
  <T extends EleventyProps>(Component: ComponentType) =>
  (props: T) =>
    (
      <DataContext.Provider value={props}>
        <Component />
      </DataContext.Provider>
    );
```

The helper function takes care of passing data from Eleventy to the provider; all we need to do is call it with our layout component:

```tsx
const Layout = () => ...;

export const render = createRender(Layout);
```

I intentionally skipped over the layout component here; we'll get to that next.

## Consuming the Context

Let's set up a hook to consume the data cascade from the context, and validate any custom data we're expecting. (This takes the place of `createEleventyComponent` in the previous post.)

```ts
import { useContext } from "preact/hooks";
import type { output } from "zod";

export const useData = <T extends ZodTypeAny>(schema: T) => {
  const data = useContext(DataContext);
  const parsed = schema.parse(data) as output<T>;
  return {
    ...data,
    ...parsed,
  };
};
```

For convenience, we can also expose a simpler hook for cases where only Eleventy's own data (which we previously defined in `EleventyProps`) is needed:

```ts
export const useEleventyData = () => useContext(DataContext);
```

With the provider hooked up and the hooks in place, we can now consume Eleventy data without needing to think about props:

```tsx
const Layout = () => {
  const { content, title } = useData(
    z.object({
      title: z.string().min(1),
    })
  );
  
  return (
    <html>
      <head>
        <title>{title}</title>
      </head>
      <body {...resolveContent(content)} />
    </html>
  );
};

export const render = createRender(Layout);
```

As with the previous approach, we only need to validate custom data, while Eleventy-supplied data is assumed from our typings.

## Further Demonstration

We have effectively moved our schema usage to within individual components, instead of wrapped around the entire layout's invocation. The value of this approach is that each component can validate the specific data it needs in exactly the same way as seen above, with no need to pass props around.

To better illustrate this, let's reorganize the layout into smaller components with more focused responsibilities.

### `Meta`

This component will handle adding metadata to the document head, using `title` and optionally `description` from custom data.

```tsx
const Meta = () => {
  const { description, title } = useData(
    z.object({
      description: z.string().optional(),
      title: z.string().min(1),
    })
  );
  return (
    <>
      <title>{title}</title>
      <meta name="og:title" content={title} />
      {!!description && (
        <>
          <meta name="description" content={description} />
          <meta name="og:description" content={description} />
        </>
      )}
    </>
  );
};
```

### `Header`

This component renders a (oversimplified) site header with navigation links. It relies on `page.url` supplied by Eleventy to determine whether to set `aria-current` on each link. This can use the simpler `useEleventyData` hook, since it doesn't need custom data and therefore has nothing to validate.

```tsx
const Header = () => {
  const { page } = useEleventyData();
  return (
    <header>
      <nav aria-label="Site">
        <ul>
          <li>
            <a href="/" aria-current={page.url === "/" ? "page" : undefined}>Home</a>
          </li>
          <li>
            <a href="/about/" aria-current={page.url === "/about/" ? "page" : undefined}>About</a>
          </li>
        </ul>
      </nav>
    </header>
  );
};
```

### `Layout`

Finally, this is what the `Layout` component looks like, using the two other components defined above:

```tsx
const Layout = () => {
  const { content } = useEleventyData();
  return (
    <html>
      <head>
        <Meta />
      </head>
      <Header />
      <body {...resolveContent(content)} />
    </html>
  );
};
```

Similar to the `Header` component, the layout only needs access to Eleventy-supplied data.

`Meta` is the only component out of the three that references custom data, so it's the only one that calls `useData` and specifies a schema. In more realistic scenarios, it's quite possible that multiple components will depend on the same properties in custom data, in which case it may make sense to centralize schemas for reusable subsets, and/or centralize a superset and make use of [`pick` or `omit`](https://zod.dev/?id=pickomit).

## Conclusion

This definitely started out as an experiment, and I imagine it will seem like overkill for some projects. I think it's a nice option to have in contrast to needing to pass down props all the time. Either way, I'm glad to have options for making Eleventy projects more maintainable going forward.