Passing data from server to client

When you output Web Components from the server, with or without the declarative shadow DOM pre-render, it’s often desirable to initialize them with server-generated, complex data.

Instead of reinventing the wheel, Gracile is just providing you the guidelines on how to achieve that, well… gracefully 😌.

Note that this isn’t even a JS framework thing. The patterns described below are transposable for any HTML-outputting backends.

Typically you’ll find two philosophies:

  1. Put the hydrating data in a <script type="application/json">{...}</script>, and from there the client-side framework will pick that up to hydrate the component tree by reconciling it with data.
    Examples: Nuxt, Next, Gatsby…
  2. Put the hydrating data in a child element attribute, typically called an “Island”.
    Examples: Astro, Fresh,…

With Lit SSR you can use both methods, though. The first one is described here.
In this mini-guide, we’ll focus on the “Island-ey” pattern, for now.

Custom Element examples

📄 /src/features/my-partial.ts

import { html } from 'lit';

 Will handle server rendering just fine as well!
import './my-lit-element.js';
import type { InitialData } from './my-lit-element.js';

export type InitialData = { foo?: string; baz?: null | number };

const data = { foo: 'bar', baz: 2 } satisfies InitialData;

export const myServerRenderedPartial = html`
  <!-- ... -->
  <section>
    <my-lit-element initialData=${JSON.stringify(data)}></my-lit-element>
  </section>
  <section>
    <my-bare-element data-initial=${JSON.stringify(data)}></my-bare-element>
  </section>
  <!-- ... -->
`;

📄 /src/features/my-partial.client.ts

import './my-lit-element.ts';
import './my-bare-element.ts';

📄 /src/features/my-lit-element.ts

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

import type { InitialData } from './my-partial.ts';

@customElement('my-lit-element')
export class MyLitElement extends LitElement {
  @property({ type: Object })
  initialData: InitialData | string = {};

  render() {
    if (typeof this.initialData !== 'object') return html`Invalid data`;

    return html`
      <!-- -->
      <div>${this.initialData.foo}</div>
      <div>${this.initialData.baz || 'Oh no'}</div>
    `;
  }
}

📄 /src/features/my-bare-element.js

import type { InitialData } from './my-partial.ts';

class MyBareElement extends HTMLElement {
  #initialData?: InitialData;

  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    const attrData = this.dataset.initial;
    if (attrData) this.#initialData = JSON.parse(attrData);

    this.shadowRoot!.innerHTML = `
        <div>${this.#initialData?.foo || 'Not defined!'}</div>
        <div>${this.#initialData?.baz || 'Not defined!'}</div>
      `;
  }
}
customElements.define('my-bare-element', MyBareElement);

Another big win by leveraging Web Component intrinsic versatility!
No need to bring superfluous JS kilobytes to do this hard work, it’s already embedded in your browser.

Please note that these examples could be improved, notably for:

initialData: InitialData | string = {};

string is just for the ts-lit-plugin to be appeased inside the html template.
Also, initialData and initialdata are equivalent, but hyphenated properties are rejected by the linter.

Maybe a bit more elegant solutions could be achieved, but those a more convention/typing issues, not runtime ones.

Advanced serialization / deserialization

If you want to pass more complex data like object with self referencing children, dates, and more, you can use a dedicated library for that, there are plenty.

For example the excellent Seroval library can be used in conjunction with a Lit @property custom converter to properly “revive” the serialized data.

This is a fully working example:


📄 /src/routes/serialization.ts

import { defineRoute } from '@gracile/gracile/route';
import { html } from 'lit';
import { serialize } from 'seroval';

import { document } from '../document.js';
import { complexData } from './_serialization-demo-data.js';

export default defineRoute({
  document: (context) => document(context),

  template: () => html`
    <serialization-example
      myComplexData=${serialize(complexData)}
    ></serialization-example>
  `,
});

📄 /src/routes/_serialization-demo-data.ts

export const complexData = {
  number: [Math.random(), -0, NaN, Infinity, -Infinity],
  string: ['hello world', '<script>Hello World</script>'],
  boolean: [true, false],
  null: null,
  undefined: undefined,
  bigint: 9007199254740991n,
  array: [, , ,], // holes
  regexp: /[a-z0-9]+/i,
  date: new Date(),
  map: new Map([['hello', 'world']]),
  set: new Set(['hello', 'world']),
} as const;

export type MyData = typeof complexData;

// self cyclic references
// recursive objects
complexData.self = complexData;
// recursive arrays
complexData.array.push(complexData.array);
// recursive maps
complexData.map.set('self', complexData.map);
// recursive sets
complexData.set.add(complexData.set);

// mutual cyclic references
complexData.array.push(complexData.map);
complexData.map.set('mutual', complexData.set);
complexData.set.add(complexData.array);

📄 /src/routes/serialization.client.ts

import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { serialize, deserialize } from 'seroval';

import type { MyData } from './_serialization-demo-data.js';

@customElement('serialization-example')
export class SerializationExample extends LitElement {
  @property({
    converter: {
      fromAttribute: (value) =>
        value ? deserialize<MyData>(value) : undefined,
    },
  })
   Reminder that `string` is used to prevent a type error in the template.
  myComplexData?: MyData | string;

  render() {
    if (typeof this.myComplexData !== 'object') return html`Invalid data`;

    return html`
      <span>Dump</span>
      <pre>${serialize(this.myComplexData)}</pre>

      <!--  -->

      ${this.myComplexData.map.size}

      <!--  -->

      ${this.myComplexData.date.getFullYear()}

      <!--  -->

      ${this.myComplexData.regexp.test('Regexp test string')}
    `;
  }
}

Refreshing data without a full page reload

For that, just set up a JSON handler in your route. Then, connect this pipe to your Web Components properties and interactions.
Could be bidirectional, too (GET/POSTResponse).