Getting started

I recommend that you read "What is Tent?" before continuing. It will give you a good overview of what Tent is and what it is not. Just to make sure you are in the right place :)

OK, let's get started!

When you are ready, you can start by installing Tent to your existing project.

npm i @tentjs/tent

Or, you can use the official starter template.

git clone @tentjs/starter my-app
cd my-app
npm install
npm run watch

That's it! You are ready to start building. Next up you should read about Components, and follow the documentation linearly from there.

Typescript

Tent is written in TypeScript. It is not required to use TypeScript, but it is highly recommended. The rest of the documentation will be written as if you are using TypeScript - because you should be.

Questions?

If you have any questions, feel free to ask in the Discussions, catch me on X, or open an issue on GitHub. I'm happy to help!

What is Tent?

Tent is a lightweight, jsx-free, no-nonsense library for building interactive web interfaces. It is built on top of the standard web APIs, so you don't need to learn a new framework to use it.

It is built with the Islands Architecture in mind, meaning that most of the web page is server-rendered, and the job of Tent is to add chunks of interactivity where it is needed.

What is Tent not?

Tent is not a framework. You won't find a router, server-side rendering, or a state management system. Tent is built to be used with the standard web APIs, so you can use it with any other library or framework.

Components

In Tent a component is a regular object with a view method.

import { type Component, mount, tags } from "@tentjs/tent";

const { p } = tags;

const HelloWorld: Component = {
  view: () => {
    return p("Hello, world!");
  },
};

mount(document.body, HelloWorld);

// or, if you want to mount a component to all elements with a specific class,
// you can use `document.querySelectorAll` and `forEach`.

document.querySelectorAll(".hello-world").forEach((el) => {
  mount(el, HelloWorld);
});

The view

The view method is the only required method in a Tent component. It is a function that returns exactly one root element.

Whenever the state of a component changes, the view method is called again, and the new element is compared with the previous one. If they are different, the component is re-rendered.

import { type Component, mount, tags } from "@tentjs/tent";

const { div, p, button } = tags;

type State = {
  count: number;
  clicked: boolean;
};

const HelloWorld: Component<State> = {
  state: { count: 0, clicked: false },
  view: ({ state }) => {
    // You can use regular JS here.
    // It is just a regular function.

    // You can define variables like this.
    // They will be re-evaluated on every render.
    const foo = "bar";

    // You can define event handlers like this,
    // or, inline in the element.
    const handleClick = () => {
      // You can update the state like this.
      state.count++;

      // or, like this for regular assignment.
      state.clicked = true;
    };

    // You have to return exactly one root element.
    // It can have any number of children.
    return div([
      p("Hello, world!"),
      p(`Foo is ${foo}`),
      div([
        p("This is a nested element."),
        state.clicked ? p("You clicked the button.") : p([]),
        p(`And also use state values: ${state.count * 2}`),
      ]),
      p(`Count is ${state.count}`),
      button("Click me!", { onclick: handleClick }),
    ]);
  },
};

mount(document.body, HelloWorld);

Tags

A tag in Tent is a function that returns an HTML element. Tent provides a set of built-in tags that you can use in your components, or you can create custom tags.

Built-in tags

The full list of built-in tags can be found here.

import { tags } from "@tentjs/tent";

const { div, p, ul, li } = tags;

div("Hello, world!");
// => <div>Hello, world!</div>

p("Hello, world!");
// => <p>Hello, world!</p>

ul([li("Item 1"), li("Item 2"), li("Item 3")]);
// => <ul>
//      <li>Item 1</li>
//      <li>Item 2</li>
//      <li>Item 3</li>
//    </ul>

Custom tags

Custom tags are useful when you want to create a tag that Tent doesn't provide.

For demonstration purposes, let's create a strong tag.

import { createTag, type Children } from "@tentjs/tent";

const strong = (children: Children, attrs?: object) =>
  createTag(["strong", children, attrs]);

// Now we can use the `strong` tag, as any other tag.
strong("Hello, world!", { style: "color: pink;" });
// => <strong style="color: pink;">Hello, world!</strong>

A real world example

import { type Component, type Children, mount, tags } from "@tentjs/tent";

const { div, p } = tags;

const strong = (children: Children, attrs?: object) =>
  createTag(["strong", children, attrs]);

const HelloWorld: Component = {
  view: () => {
    return div(p(strong("Hello, world!")));
  },
};

mount(document.body, HelloWorld);
// => <div><p><strong>Hello, world!</strong></p></div>
// Of course, inside of the body element.

Attributes

In Tent, attributes are used to add properties to HTML elements. They are passed as the second argument to the tag functions.

div("Hello, world!", { id: "my-element", class: "my-class" });
// => <div id="my-element" class="my-class">Hello, world!</div>

Event listeners

To add event listeners you can use the standard event listener syntax.

// onclick
button("Click me", {
  onclick: () => console.log("You clicked me!"),
});
// => <button>Click me</button>

// oninput
input({
  type: "text",
  oninput: (e) => console.log(e.target.value),
});
// => <input type="text" />

Custom attributes

You can add custom attributes to elements.

Custom attributes are also used to pass data to components. You can read more about that in the Passing data to components section.

div("Hello, world!", { "data-foo": "bar" });
// => <div data-foo="bar">Hello, world!</div>

Passing data to components

In Tent, you can pass data to components by using custom data-* attributes. It's simple and easy to understand.

Note: It's by design that JSON.parse() isn't called internally on all data attributes. You should be in full control of the data flow, and you should be explicit about what you expect.

<div data-list='["foo", "bar", "baz"]' data-message="Hello, world!"></div>
import { type Component, mount, tags } from "@tentjs/tent";

const { div, ul, li, p } = tags;

// This type will give your autocompletion in your editor,
// when using `el.dataset.X`. In this case it would suggest `list`, `message` and `amount`.
type Attrs = {
  list: string;
  message: string;
  amount: string;
};

// You could argue that `List` also shouldn't be a component,
// but for the sake of this example, let's keep it as a component.
const List: Component<{}, Attrs> = {
  view: ({ el }) => {
    // Cast the parsed string to an array of strings
    const list: string[] = JSON.parse(el.dataset.list);
    // No need to cast the message, since it's already defined as a string
    const message = el.dataset.message;
    // JSON.parse() will return a number, so no need to cast it
    const amount: number = JSON.parse(el.dataset.amount);

    return div([
      p(`The message is: "${message}"`),
      p(`The amount is: ${amount}`),
      ul(list.map((item, idx) => listItem(item, idx))),
    ]);
  },
};

// This is a pure function - it'll always return the same output for the same input.
// This makes it easy to test and reason about.
const listItem = (item: string, idx: number) => {
  return li(`Item ${idx}: ${item}`);
};

// In a real app, you would probably put `listItem` in a separate file.
export { List, listItem };

Nesting components

Note: Since v0.0.25 you can't nest components. Instead you can create functions that return elements.

You can't nest components in Tent. This is by design.

Instead, you can use regular functions to create reusable parts of your UI. Fundamentally, it's almost the same, but it's more explicit, easier to understand, and much easier to test.

Given that Tent is built with the Islands Architecture in mind, it makes sense to keep components as self-contained as possible. You're encouraged to keep your components small and focused on a single task.

Example

import { type Component, tags } from "@tentjs/tent";

const { ul, li } = tags;

type Attrs = {
  items: string; // JSON string with an array of strings
};

// You could argue that `List` also shouldn't be a component,
// but for the sake of this example, let's keep it as a component.
const List: Component<{}, Attrs> = {
  view: ({ attr }) => {
    // Cast the JSON string to an array of strings
    const items: string[] = JSON.parse(attr("items") ?? "[]");

    return ul(items.map((item, idx) => listItem(item, idx)));
  },
};

// This is a pure function - it'll always return the same output for the same input.
// This makes it easy to test and reason about.
const listItem = (item: string, idx: number) => {
  return li(`Item ${idx}: ${item}`);
};

// Export your `listItem` function so you can use it in other parts of your app.
// In a real app, you would probably put this in a separate file.
export { List, listItem };

Stateful components

In Tent you can create stateful components by using the state property on the component object.

Here is an example:

import { type Component, mount, tags } from "@tentjs/tent";

const { div, button } = tags;

type State = {
  count: number;
};

export const Counter: Component<State> = {
  state: { count: 0 },
  view: ({ state }) =>
    button(
      // Use the state with `state.X`
      `Clicked ${state.count} times`,
      // Update the state with an event listener
      { onclick: () => state.count++ },
    ),
};

mount(document.body, Counter);

Conditionals

Since the view() method on a component is just a regular function, you can use regular if/else statements, or the ternary operator, to conditionally render elements.

import { type Component, mount, tags } from "@tentjs/tent";

const { div, button } = tags;

type State = {
  easy: boolean;
};

const Conditionals: Component<State> = {
  state: { easy: true },
  view: ({ state }) =>
    div([
      button("Toggle", { onclick: () => (state.easy = !state.easy) }),
      p(state.easy ? "This is easy, right?" : "Damn, I thought so."),
    ]),
};

mount(document.querySelector(".recipe"), Conditionals);

Or, you can add the braces to the view function, and create a variable to hold the text, and use a regular if statement.

import { type Component, mount, tags } from "@tentjs/tent";

const Conditionals: Component<State> = {
  state: { easy: true },
  view: ({ state }) => {
    let text = "";

    if (state.easy) {
      text = "This is easy, right?";
    } else {
      text = "Damn, I thought so.";
    }

    return div([
      button("Toggle", { onclick: () => (state.easy = !state.easy) }),
      p(text),
    ]);
  },
};

mount(document.querySelector(".recipe"), Conditionals);

API

Mount

mount<S extends {} = {}>(
  element: HTMLElement | Element | TentNode | null,
  component: Component<S>,
)

The mount function is used to append a component to the specified element. The element can be an element in the document, or an element returned by a tag function.

import { type Component, mount, tags } from "@tentjs/tent";
import { Child } from "./components/Child";

const { div, p } = tags;

const HelloWorld = {
  view: () =>
    div([
      p("Hello, world!"),
      // Mount the `Child` component to the `div` element.
      mount(div([]), Child),
    ]),
};

mount(document.body, HelloWorld);

Tags

The full list of built-in tags.

Learn more about tags in the tags section.

div
p
ul
li
button
input
label
form
span
h1
h2
h3
h4
h5
h6
a
img
video
audio
canvas
table
tr
td
th
thead
tbody
tfoot
select
option
textarea
pre
code
blockquote
hr
br
iframe
nav
header
footer
main
section
article
aside
small
b

Attr

Note: attr() is deprecated as of v0.0.26. Use el.dataset instead. See Passing data for more information.

The attr<T = string>(name: string) function is used to get the value of an attribute from the component's element.

In other libraries/frameworks you might see this referred to as props or properties. But, since these are in fact just attributes on an element, they're called attributes in Tent.

import { type Component, mount, tags } from "@tentjs/tent";

const { p } = tags;

const Greeting: Component = {
  view: ({ attr }) => {
    const message = attr("message");
    // => `message` is of type `string | undefined`

    return p(message ?? "Hello, world!");
  },
};

mount(document.body, Greeting);

Typed attributes

In reality all attributes are strings, but you can use the generic type on attr<T>(name: string) to provide type information.

import { type Component, mount, tags } from "@tentjs/tent";

const { p } = tags;

enum Messages {
  Warning = "warning",
  Error = "error",
  Success = "success",
}

const Message: Component = {
  view: ({ attr }) => {
    const message = attr<Messages>("message");
    // => `message` is of type `Messages | undefined`
    // defaults to `string` if no type is provided (`string | undefined`)

    switch (message) {
      case Messages.Warning:
        return p("Warning: Something went wrong!");
      case Messages.Error:
        return p("Error: Something went wrong!");
      case Messages.Success:
        return p("Success: Everything went right!");
      default:
        return p([]);
    }
  },
};

mount(document.querySelector(".message"), Message);