Skip to main content

Custom runner with React

This guide shows how to build a custom runner using React. We will use a class component for the runner.

info

If you prefer to use Hooks for your custom runner, have a look at the Runner React Hook package. That package wraps the Runner library into an easy-to-use Hook. This makes it very easy to create a runner, but it also hides some of the magic of how Tripetto works. The class component is better to understand how Tripetto works, hence the reason why we will implement a class component in the example code below.

1️⃣ Create the runner

Tripetto uses blocks for rendering the input fields of a form. The first step for our custom runner is to define how blocks (question types) should render. For this React runner, we want to define a render function that each block must implement. So, we start with defining an interface that each block must conform to and the runner class itself that holds the state of the form.

tip

If you're not familiar with blocks yet, please read the blocks documentation first.

blocks.tsx
import { Runner, NodeBlock } from "@tripetto/runner";

export interface IBlockRenderer extends NodeBlock {
render: () => ReactNode;
}

class Blocks extends Runner<IBlockRenderer> {}

This interface extends the base class NodeBlock that is used to define the runner part of a block. It adds the function render. So, this function now needs to be defined for each block that the runner supports. The render function should return the JSX for a block.

The class Blocks is derived from the Runner class which is the workhorse of the runner. This Blocks class is responsible for rendering all the registered blocks whenever they are used in a form. So let's extend the code above with a method render that does that:

blocks.tsx
import { Runner, NodeBlock } from "@tripetto/runner";
import { ReactNode } from "react";

export interface IBlockRenderer extends NodeBlock {
render: () => ReactNode;
}

export class Blocks extends Runner<IBlockRenderer> {
render(): ReactNode {
return (
this.storyline?.nodes.map((node) => (
<div key={node.id}>
<div><b>{node.name}</b></div>
<div>{node.description}</div>
{node.block?.render()}
</div>
))
);
}
}

This method iterates through the list of active nodes using the nodes property of the storyline object. This nodes property returns all the fields of a form that should render. For each block, it then calls the render method we defined earlier returning the JSX for the form.

info

It is possible that a node doesn't have a block instance attached (the block property of the node is then undefined). This indicates that the node is a static text element.

2️⃣ Create the class component

The next step is to define the actual class component. This component will create an instance of the Blocks class we've defined in the previous step. The only thing our class component needs to do is to render the blocks whenever there is a change in the state of the Block instance. Luckily, the Blocks instance has an event we can listen to. It will fire for each change in the state of the form.

component.tsx
import { IDefinition, Export } from "@tripetto/runner";
import { PureComponent, ReactNode } from "react";
import { Blocks } from "./blocks";

export class CustomRunner extends PureComponent<{
definition: IDefinition;
onSubmit?: (data: Export.IExportables) => void;
}> {
readonly blocks = new Blocks({
definition: this.props.definition,
mode: "paginated",
start: true,
});

render(): ReactNode {
return (
<div>
{this.blocks.render() || "This form is empty!"}
</div>
);
}

componentDidMount(): void {
// When there is a change, rerender the component
this.blocks.onChange = () => this.forceUpdate();

// When the form completes, emit the form data
this.blocks.onFinish = (instance) => {
if (this.props.onSubmit) {
this.props.onSubmit(Export.exportables(instance));
}

return true;
};
}

componentWillUnmount(): void {
// Cleanup when the component is destroyed
this.blocks.destroy();
}
}

As you can see we define a new (pure) React component named CustomRunner. It has a blocks property which is a Blocks instance. The form definition we want to run is supplied to the definition property of the constructor.

When the component mounts, two events are tracked. First of all, the onChange event is defined. This event will fire whenever there was a change in the form. In that case, the form needs to be rerendered (updated), so it invokes the forceUpdate method of the class component.

The other event we use is onFinish. That event will fire when a form is completed and the form data is available. In this case, we extract the form data and push it to the optional onSubmit prop of the component.

When the class component is unmounted, we can destroy the Blocks instance.

Now we have a fully functioning class component that can run a Tripetto form!

3️⃣ Define blocks

The code above can run a form, but we still need to define the actual blocks (question fields) that can be used in the form. Tripetto uses dependency injection to let this work. Blocks are registered using a special class decorator that makes the block available to the runner. Let's look at the basic code for defining a block:

hello-world-block.tsx
import { tripetto, NodeBlock } from "@tripetto/runner";
import { ReactNode } from "react";
import { IBlockRenderer } from "./blocks";

@tripetto({
type: "node",
identifier: "hello-world",
})
export class HelloWorldBlock extends NodeBlock implements IBlockRenderer {
render(): ReactNode {
return (
<div>
Hello world!
</div>
);
}
}

Here we define a block named HelloWorldBlock. It is registered using the @tripetto decorator using the identifier hello-world. This identifier is used in the form definition to select the right block for a node. The block implements the IBlockRenderer interface, which requires the render method that will do the actual rendering of the block. So the IBlockRenderer interface serves as a contract between the runner component and the blocks.

tip

See the block implementation guide to learn how to develop blocks for Tripetto.

4️⃣ Use the component

Now we can use the component in a React application.

app.tsx
import { CustomRunner } from "./component";

function App() {
return (
<div>
<CustomRunner
definition={{
name: "Example form",
sections: [
{
"id": "0abbc4d4aacecbb8f1aa13e77c6ab7a58c416aefcc42f4fd77d6a1a46a4e3afa",
"nodes": [
{
"id": "362221f5ed388ec503a186f66be7829b542cb307b4f43e91473838a5f8c5ac75",
"block": { "type": "hello-world", "version": "1.0.0" }
}
]
}
]
}}
onSubmit={(data) => {
console.log('Form completed!');

data.fields.forEach((field) => {
if (field.string) {
console.log(`${field.name}: ${field.string}`);
}
});
}}
</div>
);
}

🧑‍💻 Code examples

Have a look at the following fully working custom runner examples for React.


React + Bootstrap

Custom runner example that uses React and Bootstrap.

Demo Source code


React + Material UI

Custom runner example that uses React and Material UI.

Demo Source code