Skip to main content
While IHP has a strong preference for server-side rendering, specific functionality in your app might require interactivity that can only be implemented with JavaScript. In these cases we recommend a hybrid approach: implement interactive functionality as a small single page app, while keeping unrelated functionality server-side rendered.

Introduction

The hybrid approach gives you the best of both worlds: the low interactivity of a single page app where needed, while keeping the simplicity and high performance advantages of server side rendered IHP apps. This guide will help you understand the best-practices of building hybrid applications with IHP and React.

Adding React to your IHP Project

1

Add NodeJS to your project

Open your project’s flake.nix and add nodejs to packages:
packages = with pkgs; [
    # Native dependencies, e.g. imagemagick
    nodejs
];
Rebuild your local dev environment:
devenv up
After that, you have node and npm available in your project.
2

Create the Frontend directory

mkdir Frontend
3

NPM Init

Before installing dependencies, generate a package.json:
cd Frontend
npm init
Add the Frontend/package.json and Frontend/package.lock to your git repository.
4

Install React and dependencies

cd Frontend

# Install react and react-dom
npm add react react-dom

# We also need esbuild for bundling
npm add esbuild

# Install IHP JS helpers
npm add "https://gitpkg.now.sh/digitallyinduced/ihp/lib/IHP/DataSync?0babfec7a90675ca37d56c38dac69e784e64fc83"
5

Add an entrypoint

Create a new file Frontend/app.jsx:
import * as React from 'react';
import * as ReactDOM from 'react-dom';

import { DataSubscription, createRecord, updateRecord, deleteRecord, createRecords } from 'ihp-datasync/ihp-datasync';
import { query } from 'ihp-datasync/ihp-querybuilder';
import { useQuery } from 'ihp-datasync/ihp-datasync-react';

function HelloWorld() {
    return <div>
        Hello from react!
    </div>
}

function startApp() {
    ReactDOM.render(<HelloWorld/>, document.getElementById('hello-world'));
}

$(document).on('ready turbolinks:load', function () {
    // This is called on the first page load *and* also when the page is changed by turbolinks
    startApp();
});
6

Add Make tasks

Open your project’s Makefile and append these tasks:
Frontend/node_modules:
    cd Frontend && npm install

static/app.js: Frontend/node_modules Frontend/app.jsx
    cd Frontend && ./node_modules/.bin/esbuild app.jsx --bundle --outfile=../static/app.js ${ESBUILD_FLAGS}

watch-frontend:
    touch Frontend/app.jsx # Force rebuild
    $(MAKE) static/app.js ESBUILD_FLAGS="--watch"
To get the JS automatically built on deployment, append at the top of the file:
JS_FILES += static/app.js
7

Run the bundler

Open a new terminal tab and start the dev bundler:
make watch-frontend
8

Mount the React component

Open Web/Static/Welcome.hs and add this div:
<div id="hello-world"/>
Open the view in the browser. You should see Hello from react! on the page.

Building a Real-time SPA with IHP DataSync

The following section assumes that your app already has a login as described in the Authentication section.
IHP DataSync provides functions like query, fetchOne or createRecord in JavaScript, similar to the Haskell side. Example fetching posts:
const posts = await query('posts')
    .orderBy('createdAt')
    .fetch()
Compare with Haskell:
posts <- query @Post
    |> orderBy #createdAt
    |> fetch

Setting up the Schema

In this example we’re building a simple todo list.
1

Create todos table

Create a new table todos with the IHP Schema Designer:
CREATE TABLE todos (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
    title TEXT NOT NULL,
    is_completed BOOLEAN DEFAULT false NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
    user_id UUID NOT NULL
);
CREATE INDEX todos_user_id_index ON todos (user_id);
ALTER TABLE todos ADD CONSTRAINT todos_ref_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
Run Update DB to insert the new table.
2

Add the ihp_authenticated role

IHP DataSync uses Postgres Row Level Security. The default database role is the table owner and can access all rows. DataSync uses a second role called ihp_authenticated.Open Config/Config.hs:
import qualified IHP.DataSync.Role as Role

config :: ConfigBuilder
config = do
    -- ...
    addInitializer Role.ensureAuthenticatedRoleExists
3

Enable the DataSync controllers

Open Web/FrontController.hs and add imports:
-- DataSync
import IHP.DataSync.Types
import IHP.DataSync.Controller
import IHP.DataSync.REST.Types
import IHP.DataSync.REST.Controller
Mount the controller:
instance FrontController WebApplication where
    controllers =
        [ startPage WelcomeAction

        -- DataSync
        , webSocketApp @DataSyncController
        , parseRoute @ApiController

        -- Generator Marker
        ]
Open Web/Routes.hs and add:
import IHP.DataSync.REST.Routes
4

Load the JS SDK

Open Web/View/Layout.hs and add these JavaScript files:
scripts :: Html
scripts = [hsx|
        <!-- ... -->

        <!-- DataSync -->
        <script src={assetPath "/vendor/ihp-datasync.js"}></script>
        <script src={assetPath "/vendor/ihp-querybuilder.js"}></script>

        <!-- Make sure datasync files are before app.js -->
        <script src={assetPath "/app.js"}></script>
    |]
Remove the helpers.js from your Layout - it typically causes troubles with React.
5

Define a security policy

Open the Schema Designer, select the todos table, and click Add Policy.IHP detected the user_id column and auto-suggests:
  • Visible if: user_id = ihp_user_id()
  • Additionally, allow INSERT and UPDATE only if: user_id = ihp_user_id()
Click Create Policy and run Update DB.

Fetching Data

Open your app and try this in the JS console:
await query('todos').fetch();
This will return an empty array. Let’s create a todo:
await createRecord('todos', { title: 'Hello World!', userId: '<PUT YOUR USER ID HERE>' })
Now fetch again:
await query('todos').fetch();
You’ll see the todo in the result.

Implementing the Todo List

1

Display todos

Open Frontend/app.jsx and add this component:
function TodoList() {
    const todos = useQuery(query('todos'));

    if (todos === null) {
        return <div className="spinner-border text-primary" role="status">
            <span className="sr-only">Loading...</span>
        </div>;
    }

    return <div>
        {todos.map(todo => <div>{todo.title}</div>)}
    </div>
}
Update HelloWorld to render it:
function HelloWorld() {
    return <div>
        <TodoList/>
    </div>
}
2

Add todo form

Add this component:
function NewTodo({ userId }) {
    let [title, setTitle] = useState("");
    let [loading, setLoading] = useState(false);

    async function handleSubmit(event) {
        event.preventDefault();

        setLoading(true);
        await createRecord('todos', { title: title, userId: userId });

        setLoading(false);
        setTitle('');
    }

    return <form onSubmit={handleSubmit} disabled={loading}>
        <div className="mb-3 d-flex flex-row">
            <input
                type="text"
                className="form-control"
                placeholder="New todo"
                value={title}
                onChange={event => setTitle(event.target.value)}
                disabled={loading}
            />

            <button type="submit" className="btn btn-primary" disabled={loading}>Save</button>
        </div>
    </form>
}
3

Make it realtime

The useQuery(query('todos')) call automatically sets up a subscription. Changes appear in real-time across all browser windows.
4

Add checkboxes

Add a TodoItem component:
function TodoItem({ todo }) {
    const todoIdAttr = "todo-" + todo.id;

    return <div className="mb-3 form-check">
        <input
            id={todoIdAttr}
            type="checkbox"
            checked={todo.isCompleted}
            onChange={() => updateRecord('todos', todo.id, { isCompleted: !todo.isCompleted })}
            className="me-2"
        />
        <label className="form-check-label" htmlFor={todoIdAttr}>{todo.title}</label>
    </div>
}
Update TodoList to use it:
return <div>
    {todos.map(todo => <TodoItem todo={todo} key={todo.id}/>)}
</div>
5

Add delete button

Add to TodoItem:
<button className="btn btn-link text-danger" onClick={() => deleteRecord('todos', todo.id)}>Delete</button>

Common DataSync Operations

Querying Records

const todos = await query('todos').fetch();

for (const todo of todos) {
    console.log(todo.title);
}

Realtime Queries

Use subscribe to keep the result set in sync:
function callback(todos) {
    console.log('todos did change', todos);
}

const todos = await query('todos').subscribe(callback);

Filtering

const todos = await query('todos')
    .where('id', 'd94173ec-1d91-421e-8fdc-20a3161b7802')
    .fetch()

// Other operators:
const todos = await query('todos')
    .whereLessThan('createdAt', '2024-01-01')
    .whereGreaterThan('priority', 5)
    .fetch()

Fetching a Single Record

const todo = await query('todos')
    .where('id', 'd94173ec-1d91-421e-8fdc-20a3161b7802')
    .fetchOne();

// Returns null if not found

Ordering

const latestTodos = await query('todos')
    .orderByDesc('createdAt')
    .fetch();

const oldestTodos = await query('todos')
    .orderBy('createdAt')
    .fetch();

Create Record

const newTodo = {
    title: 'Finish Guide',
    userId: '49946f4d-8a2e-4f18-a399-58e3296ecff5'
};

const insertedTodo = await createRecord('todos', newTodo);
console.log('id', insertedTodo.id);

Create Many Records

const todoA = { title: 'Finish Guide', userId: '49946f4d-8a2e-4f18-a399-58e3296ecff5' };
const todoB = { title: 'Learn Haskell', userId: '49946f4d-8a2e-4f18-a399-58e3296ecff5' };

const todos = await createRecords('todos', [todoA, todoB]);

Update Record By ID

const todo = await updateRecord('todos', '66cc037e-5729-435c-b507-a17492fe44f4', { isCompleted: false });

Delete Record By ID

await deleteRecord('todos', '66cc037e-5729-435c-b507-a17492fe44f4');

Using DataSync Without a Bundler

In your Web/View/Layout.hs file, add an importmap:
<!-- DataSync -->
<script async src="https://cdn.jsdelivr.net/npm/[email protected]/dist/es-module-shims.min.js"></script>
<script type="importmap">
    {
        "imports": {
            "ihp-datasync/ihp-datasync": "https://cdn.jsdelivr.net/gh/digitallyinduced/[email protected]/lib/IHP/DataSync/ihp-datasync.min.js",
            "ihp-datasync/ihp-querybuilder": "https://cdn.jsdelivr.net/gh/digitallyinduced/[email protected]/lib/IHP/DataSync/ihp-querybuilder.min.js"
        }
    }
</script>
Add type="module" to your app.js script:
<script type="module" src={assetPath "/app.js"}></script>
Inside app.js, you can now import:
import {
  DataSyncController,
  DataSubscription,
  createRecord,
  updateRecord,
  deleteRecord,
  createRecords,
} from "ihp-datasync/ihp-datasync";
import {
  QueryBuilder,
  query,
  ihpBackendUrl,
  fetchAuthenticated,
} from "ihp-datasync/ihp-querybuilder";

const articles = await query("articles").fetch();
console.log(articles);

Build docs developers (and LLMs) love