Custom runner with React
This guide shows how to build a custom runner using React. We will use a class component for the runner.
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.
If you're not familiar with blocks yet, please read the blocks documentation first.
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:
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.
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.
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:
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.
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.
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.
React + Material UI
Custom runner example that uses React and Material UI.