Skip to main content

Documentation Index

Fetch the complete documentation index at: https://domoinc-arun-raj-connetors-domo-480645-add-reports-sort-asc.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Intro


This tutorial walks through building a shared Todo app with React, TypeScript, Redux Toolkit, and AppDB. The app is scaffolded with the DA CLI, loads the current Domo user’s identity and avatar, and stamps ownership onto every todo so users can see who added what. You’ll learn the patterns you’ll reach for in every real Domo app:
  • Scaffold a Vite + React + TypeScript project with da new
  • Declare an AppDB collection in manifest.json and publish an initial design
  • Build a typed service layer over AppDBClient, IdentityClient, and UserClient
  • Manage async state with Redux Toolkit’s buildCreateSlice + asyncThunkCreator
  • Compose small, presentational components that read from and dispatch against the store
The finished code is at DomoApps/basic-react-app-todo-tutorial on GitHub.
Prerequisite: Complete the Setup and Installation guide and run domo login before starting.

Step 1: Install the DA CLI and scaffold the app


The DA CLI clones the @domoinc/vite-react-template — a Vite + React + TypeScript project preconfigured with the Domo proxy, ESLint, Prettier, Vitest, Storybook, and da generate scaffolding. Install the CLI globally:
# pnpm (recommended)
pnpm add -g @domoinc/da

# or yarn
yarn global add @domoinc/da

# or npm
npm install -g @domoinc/da
Create the project:
da new todo-appdb-demo
cd todo-appdb-demo
da new prompts for a package manager (pick pnpm), clones the template, writes your app name, initializes git, and installs dependencies.
App names must be lowercase with hyphens only. Capitals, underscores, and periods are rejected.
The scaffold gives you:
  • public/manifest.json — app metadata, size, collections
  • public/thumbnail.png — thumbnail shown in the Asset Library
  • src/main.tsx — React entry point
  • src/manifestOverrides.json — per-environment manifest overrides (dev/qa/prod)
  • A Vite dev server wired through @domoinc/ryuu-proxy to your Domo instance
  • Scripts: start, build, upload, generate, test, storybook
Add the two runtime dependencies we’ll use beyond the scaffold:
pnpm add @reduxjs/toolkit react-redux @domoinc/toolkit

Step 2: Declare the Todos collection


Replace public/manifest.json with:
{
  "name": "Todo AppDB Demo",
  "version": "0.0.1",
  "size": { "width": 5, "height": 3 },
  "fullpage": true,
  "mapping": [],
  "collections": [
    {
      "name": "Todos",
      "syncEnabled": true,
      "schema": {
        "columns": [
          { "name": "title", "type": "STRING" },
          { "name": "completed", "type": "STRING" },
          { "name": "priority", "type": "STRING" },
          { "name": "dueDate", "type": "STRING" },
          { "name": "ownerId", "type": "STRING" },
          { "name": "ownerName", "type": "STRING" }
        ]
      },
      "defaultPermission": [
        "READ",
        "READ_CONTENT",
        "CREATE_CONTENT",
        "UPDATE_CONTENT",
        "DELETE_CONTENT"
      ]
    }
  ]
}
Note: AppDB columns are always STRING. Booleans are stored as "true" / "false" and parsed at the UI layer. ownerId and ownerName give us the basis for per-user ownership displays and filters.

Step 3: Publish the initial design and wire the proxy


Before local dev can read or write documents, Domo needs to provision the collection and hand you a proxyId.
pnpm upload
This runs pnpm build and domo publish from the build/ folder. The output prints a link to the new App Design. Then, in the Domo UI:
  1. Open the App Design link.
  2. Click New CardSelect CollectionCreate NewCreate Collection to provision an empty Todos collection.
  3. Save the card.
Copy the App Design id and the card’s proxyId from the design page, and add both to public/manifest.json:
{
  "id": "23307940-6dfe-40c4-86f8-8a7b0f5d8b3a",
  "proxyId": "95bd96f9-0385-465a-b485-c16935cf771a"
}
For multi-environment apps, use da manifest to add overrides to src/manifestOverrides.json instead of hand-editing manifest.json. da apply-manifest runs automatically as a prestart / prebuild hook.

Step 4: Types and service layer


Create src/services/types.ts:
import type { Identity, User } from '@domoinc/toolkit/src/models';

export interface TodoData {
  title: string;
  completed: string;   // "true" | "false"
  priority: string;    // "low" | "medium" | "high"
  dueDate: string;
  ownerId: string;
  ownerName: string;
}

export interface Todo extends TodoData {
  id: string;
  createdOn?: string;
  updatedOn?: string;
}

export interface UserInfo {
  identity: Identity;
  user: User;
  avatarUrl: string;
}
Create src/services/app.ts:
import type { AppDBDocument } from '@domoinc/toolkit';
import { AppDBClient, IdentityClient, UserClient } from '@domoinc/toolkit';

import type { Todo, TodoData, UserInfo } from './types';

const TodosClient = new AppDBClient.DocumentsClient<TodoData>('Todos');

const extractDocs = (docs: AppDBDocument<TodoData>[]): Todo[] =>
  docs.map((doc) => ({
    id: doc.id,
    createdOn: doc.createdOn,
    updatedOn: doc.updatedOn,
    ...doc.content,
  }));

export const AppService = {
  async loadUserInfo(): Promise<UserInfo> {
    const identity = (await IdentityClient.get(undefined, true)).data;
    const user = (await UserClient.getUser(identity.userId, true)).data;
    const avatarUrl = `/api/content/v1/avatar/USER/${identity.userId}?size=100${
      user.avatarKey ? `&v=${encodeURIComponent(user.avatarKey)}` : ''
    }`;
    return { identity, user, avatarUrl };
  },

  async loadTodos(): Promise<Todo[]> {
    const response = await TodosClient.get(
      {},
      { orderby: ['createdOn descending'] },
    );
    return extractDocs(response.data);
  },

  async createTodo(data: TodoData): Promise<Todo> {
    const response = await TodosClient.create(data);
    return extractDocs(
      Array.isArray(response.data) ? response.data : [response.data],
    )[0];
  },

  async updateTodo(id: string, data: TodoData): Promise<Todo> {
    const response = await TodosClient.update({ id, content: data });
    return extractDocs([response.data])[0];
  },

  async deleteTodo(id: string): Promise<string> {
    await TodosClient.delete(id);
    return id;
  },
};
Two things worth calling out:
  • IdentityClient + UserClient give you the current user’s ID, email, display name, role, and avatar — everything you need to stamp ownership on documents and render a friendly header.
  • extractDocs flattens the AppDB envelope ({ id, content, createdOn, ... }) into plain todo objects, so the rest of the app never has to think about document metadata.

Step 5: Redux store with buildCreateSlice


We’ll use Redux Toolkit’s buildCreateSlice with the asyncThunkCreator so that async thunks can be declared inline on each slice — no separate actions.ts file. Create src/reducers/createAppSlice.ts:
import { asyncThunkCreator, buildCreateSlice } from '@reduxjs/toolkit';

export const createAppSlice = buildCreateSlice({
  creators: { asyncThunk: asyncThunkCreator },
});
Create src/reducers/index.ts:
import {
  type Action,
  configureStore,
  type ThunkAction,
} from '@reduxjs/toolkit';
import {
  type TypedUseSelectorHook,
  useDispatch,
  useSelector,
} from 'react-redux';

import AppSlice from './app/slice';
import TodosSlice from './todos/slice';

export const store = configureStore({
  reducer: {
    app: AppSlice,
    todos: TodosSlice,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
App slice — user identity. Create src/reducers/app/slice.ts:
import { AppService } from 'services/app';
import type { UserInfo } from 'services/types';

import { createAppSlice } from '../createAppSlice';

interface State {
  userInfo: UserInfo | null;
  loading: boolean;
  error: string | null;
}

const initialState: State = {
  userInfo: null,
  loading: false,
  error: null,
};

export const AppSlice = createAppSlice({
  name: 'app',
  initialState,
  reducers: (create) => ({
    loadUserInfo: create.asyncThunk<UserInfo, void>(
      async () => await AppService.loadUserInfo(),
      {
        pending: (state) => {
          state.loading = true;
          state.error = null;
        },
        fulfilled: (state, { payload }) => {
          state.loading = false;
          state.userInfo = payload;
        },
        rejected: (state, action) => {
          state.loading = false;
          state.error = action.error.message ?? 'Failed to load user';
        },
      },
    ),
  }),
  selectors: {
    selectUserInfo: (state) => state.userInfo,
    selectUserLoading: (state) => state.loading,
    selectUserError: (state) => state.error,
  },
});

export const { loadUserInfo } = AppSlice.actions;
export const { selectUserInfo, selectUserLoading, selectUserError } =
  AppSlice.selectors;

export default AppSlice.reducer;
Todos slice — CRUD thunks. Create src/reducers/todos/slice.ts:
import { AppService } from 'services/app';
import type { Todo, TodoData } from 'services/types';

import { createAppSlice } from '../createAppSlice';

interface State {
  items: Todo[];
  loading: boolean;
  saving: boolean;
  error: string | null;
}

const initialState: State = {
  items: [],
  loading: false,
  saving: false,
  error: null,
};

export const TodosSlice = createAppSlice({
  name: 'todos',
  initialState,
  reducers: (create) => ({
    loadTodos: create.asyncThunk<Todo[], void>(
      async () => await AppService.loadTodos(),
      {
        pending: (state) => {
          state.loading = true;
          state.error = null;
        },
        fulfilled: (state, { payload }) => {
          state.loading = false;
          state.items = payload;
        },
        rejected: (state, action) => {
          state.loading = false;
          state.error = action.error.message ?? 'Failed to load todos';
        },
      },
    ),

    createTodo: create.asyncThunk<Todo, TodoData>(
      async (data) => await AppService.createTodo(data),
      {
        pending: (state) => {
          state.saving = true;
        },
        fulfilled: (state, { payload }) => {
          state.saving = false;
          state.items = [payload, ...state.items];
        },
        rejected: (state, action) => {
          state.saving = false;
          state.error = action.error.message ?? 'Failed to create todo';
        },
      },
    ),

    updateTodo: create.asyncThunk<Todo, { id: string; data: TodoData }>(
      async ({ id, data }) => await AppService.updateTodo(id, data),
      {
        pending: (state) => {
          state.saving = true;
        },
        fulfilled: (state, { payload }) => {
          state.saving = false;
          state.items = state.items.map((t) =>
            t.id === payload.id ? payload : t,
          );
        },
        rejected: (state, action) => {
          state.saving = false;
          state.error = action.error.message ?? 'Failed to update todo';
        },
      },
    ),

    toggleTodo: create.asyncThunk<Todo, Todo>(
      async (todo) => {
        const data: TodoData = {
          title: todo.title,
          completed: todo.completed === 'true' ? 'false' : 'true',
          priority: todo.priority,
          dueDate: todo.dueDate,
          ownerId: todo.ownerId,
          ownerName: todo.ownerName,
        };
        return await AppService.updateTodo(todo.id, data);
      },
      {
        fulfilled: (state, { payload }) => {
          state.items = state.items.map((t) =>
            t.id === payload.id ? payload : t,
          );
        },
      },
    ),

    deleteTodo: create.asyncThunk<string, string>(
      async (id) => await AppService.deleteTodo(id),
      {
        fulfilled: (state, { payload }) => {
          state.items = state.items.filter((t) => t.id !== payload);
        },
      },
    ),
  }),
  selectors: {
    selectTodos: (state) => state.items,
    selectTodosLoading: (state) => state.loading,
    selectTodosSaving: (state) => state.saving,
    selectTodosError: (state) => state.error,
  },
});

export const { loadTodos, createTodo, updateTodo, toggleTodo, deleteTodo } =
  TodosSlice.actions;
export const {
  selectTodos,
  selectTodosLoading,
  selectTodosSaving,
  selectTodosError,
} = TodosSlice.selectors;

export default TodosSlice.reducer;
A few patterns to notice:
  • loading vs savingloading covers the initial list fetch; saving is toggled by creates, updates, and toggles so the form can disable its submit button without blanking the list.
  • toggleTodo takes the whole todo (not just an ID) so the reducer can build the full TodoData payload without refetching.
  • Selectors declared on the slice — callers never reach into state.todos.items directly; they import selectTodos and get type safety for free.

Step 6: Components


Each component lives in its own folder under src/components/ with a matching .module.scss. We’ll show the TypeScript; grab the SCSS from the GitHub source. UserHeader renders the current user’s avatar, name, role, and email, with a gradient-initials fallback if the avatar URL 404s.
// src/components/UserHeader/UserHeader.tsx
import { FC, useState } from 'react';
import { useAppSelector } from 'reducers';
import { selectUserInfo, selectUserLoading } from 'reducers/app/slice';

import styles from './UserHeader.module.scss';

export const UserHeader: FC = () => {
  const userInfo = useAppSelector(selectUserInfo);
  const loading = useAppSelector(selectUserLoading);
  const [avatarFailed, setAvatarFailed] = useState(false);

  if (loading && !userInfo) {
    return <div className={styles.header}>Loading user…</div>;
  }
  if (!userInfo) return null;

  const { identity, user, avatarUrl } = userInfo;
  const initials = user.displayName
    ?.split(' ')
    .map((part) => part[0])
    .filter(Boolean)
    .slice(0, 2)
    .join('')
    .toUpperCase();

  return (
    <div className={styles.header}>
      {avatarFailed ? (
        <div className={styles.avatarFallback}>{initials || '?'}</div>
      ) : (
        <img
          src={avatarUrl}
          alt={user.displayName}
          className={styles.avatar}
          onError={() => setAvatarFailed(true)}
        />
      )}
      <div className={styles.info}>
        <div className={styles.name}>{user.displayName}</div>
        <div className={styles.meta}>{identity.userEmail}</div>
        {user.detail?.title && (
          <div className={styles.meta}>{user.detail.title}</div>
        )}
        <span className={styles.role}>{user.role}</span>
      </div>
    </div>
  );
};
TodoForm is a simple controlled form that stamps ownerId / ownerName from the store onto every new todo at submit time.
// src/components/TodoForm/TodoForm.tsx
import { FC, FormEvent, useState } from 'react';
import { useAppDispatch, useAppSelector } from 'reducers';
import { selectUserInfo } from 'reducers/app/slice';
import { createTodo, selectTodosSaving } from 'reducers/todos/slice';

import styles from './TodoForm.module.scss';

export const TodoForm: FC = () => {
  const dispatch = useAppDispatch();
  const userInfo = useAppSelector(selectUserInfo);
  const saving = useAppSelector(selectTodosSaving);

  const [title, setTitle] = useState('');
  const [priority, setPriority] = useState('medium');
  const [dueDate, setDueDate] = useState('');

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    if (!title.trim() || !userInfo) return;

    await dispatch(
      createTodo({
        title: title.trim(),
        completed: 'false',
        priority,
        dueDate,
        ownerId: String(userInfo.identity.userId),
        ownerName: userInfo.user.displayName,
      }),
    );

    setTitle('');
    setDueDate('');
    setPriority('medium');
  };

  return (
    <form className={styles.form} onSubmit={handleSubmit}>
      <input
        type="text"
        className={styles.title}
        placeholder="What needs to be done?"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <select
        className={styles.select}
        value={priority}
        onChange={(e) => setPriority(e.target.value)}
      >
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>
      <input
        type="date"
        className={styles.date}
        value={dueDate}
        onChange={(e) => setDueDate(e.target.value)}
      />
      <button
        type="submit"
        className={styles.submit}
        disabled={saving || !title.trim() || !userInfo}
      >
        {saving ? 'Adding…' : 'Add Todo'}
      </button>
    </form>
  );
};
TodoItem renders a single row — checkbox, title, owner, due date, and delete button. A priority class on the container drives the colored left border.
// src/components/TodoItem/TodoItem.tsx
import { FC } from 'react';
import { useAppDispatch } from 'reducers';
import { deleteTodo, toggleTodo } from 'reducers/todos/slice';
import type { Todo } from 'services/types';

import styles from './TodoItem.module.scss';

interface Props {
  todo: Todo;
}

const priorityClass: Record<string, string> = {
  high: styles.priorityHigh,
  medium: styles.priorityMedium,
  low: styles.priorityLow,
};

export const TodoItem: FC<Props> = ({ todo }) => {
  const dispatch = useAppDispatch();
  const isCompleted = todo.completed === 'true';

  return (
    <li className={`${styles.item} ${priorityClass[todo.priority] ?? ''}`}>
      <input
        type="checkbox"
        className={styles.checkbox}
        checked={isCompleted}
        onChange={() => dispatch(toggleTodo(todo))}
      />
      <div className={styles.content}>
        <div className={`${styles.title} ${isCompleted ? styles.completed : ''}`}>
          {todo.title}
        </div>
        <div className={styles.meta}>
          <span>By {todo.ownerName || 'Unknown'}</span>
          {todo.dueDate && <span>Due {todo.dueDate}</span>}
          <span>{todo.priority}</span>
        </div>
      </div>
      <button
        type="button"
        className={styles.delete}
        onClick={() => dispatch(deleteTodo(todo.id))}
        aria-label="Delete todo"
      >
        ×
      </button>
    </li>
  );
};
TodoList handles filtering (all / active / completed) and the count header.
// src/components/TodoList/TodoList.tsx
import { TodoItem } from 'components/TodoItem/TodoItem';
import { FC, useState } from 'react';
import { useAppSelector } from 'reducers';
import { selectTodos, selectTodosLoading } from 'reducers/todos/slice';

import styles from './TodoList.module.scss';

type Filter = 'all' | 'active' | 'completed';

export const TodoList: FC = () => {
  const todos = useAppSelector(selectTodos);
  const loading = useAppSelector(selectTodosLoading);
  const [filter, setFilter] = useState<Filter>('all');

  const filtered = todos.filter((t) => {
    if (filter === 'active') return t.completed !== 'true';
    if (filter === 'completed') return t.completed === 'true';
    return true;
  });
  const activeCount = todos.filter((t) => t.completed !== 'true').length;

  if (loading && todos.length === 0) {
    return <div className={styles.count}>Loading todos…</div>;
  }

  return (
    <div>
      <div className={styles.header}>
        <span className={styles.count}>
          {activeCount} active · {todos.length} total
        </span>
        <div className={styles.filters}>
          {(['all', 'active', 'completed'] as Filter[]).map((f) => (
            <button
              key={f}
              type="button"
              className={`${styles.filterBtn} ${filter === f ? styles.active : ''}`}
              onClick={() => setFilter(f)}
            >
              {f}
            </button>
          ))}
        </div>
      </div>
      {filtered.length === 0 ? (
        <div className={styles.count} style={{ padding: '16px 0' }}>
          No todos to show.
        </div>
      ) : (
        <ul className={styles.list}>
          {filtered.map((todo) => (
            <TodoItem key={todo.id} todo={todo} />
          ))}
        </ul>
      )}
    </div>
  );
};
App is the root: it kicks off both initial loads in a useEffect, surfaces any error from either slice in a banner, and renders the header, form, and list.
// src/components/App/App.tsx
import { TodoForm } from 'components/TodoForm/TodoForm';
import { TodoList } from 'components/TodoList/TodoList';
import { UserHeader } from 'components/UserHeader/UserHeader';
import { FC, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from 'reducers';
import { loadUserInfo, selectUserError } from 'reducers/app/slice';
import { loadTodos, selectTodosError } from 'reducers/todos/slice';

import styles from './App.module.scss';

export const App: FC = () => {
  const dispatch = useAppDispatch();
  const userError = useAppSelector(selectUserError);
  const todosError = useAppSelector(selectTodosError);

  useEffect(() => {
    dispatch(loadUserInfo());
    dispatch(loadTodos());
  }, [dispatch]);

  return (
    <div className={styles.App}>
      <h1 className={styles.title}>My Todos</h1>
      <UserHeader />
      {(userError || todosError) && (
        <div className={styles.errorBanner}>{userError || todosError}</div>
      )}
      <TodoForm />
      <TodoList />
    </div>
  );
};

Step 7: Wire up main.tsx


Replace src/main.tsx with:
import './index.scss';

import { App } from 'components/App/App';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from 'reducers';

declare const DOMO_APP_NAME: string;
declare const DOMO_APP_VERSION: string;

if (import.meta.env.DEV) {
  console.log(`${DOMO_APP_NAME}@${DOMO_APP_VERSION}`);
}

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </StrictMode>,
);
DOMO_APP_NAME and DOMO_APP_VERSION are injected by the Vite template from manifest.json — useful for logging and telemetry. Your final src/ tree:
src/
├── main.tsx
├── index.scss
├── components/
│   ├── App/
│   │   ├── App.tsx
│   │   └── App.module.scss
│   ├── TodoForm/
│   │   ├── TodoForm.tsx
│   │   └── TodoForm.module.scss
│   ├── TodoItem/
│   │   ├── TodoItem.tsx
│   │   └── TodoItem.module.scss
│   ├── TodoList/
│   │   ├── TodoList.tsx
│   │   └── TodoList.module.scss
│   └── UserHeader/
│       ├── UserHeader.tsx
│       └── UserHeader.module.scss
├── reducers/
│   ├── index.ts
│   ├── createAppSlice.ts
│   ├── app/
│   │   └── slice.ts
│   └── todos/
│       └── slice.ts
└── services/
    ├── app.ts
    └── types.ts

Step 8: Test locally


pnpm start
The Vite dev server starts on port 3000 (or 3001/3002 if busy). Because proxyId is set, AppDBClient, IdentityClient, and UserClient all talk to your real Domo instance — create, toggle, and delete todos and they persist across reloads. Log in as a different user and you’ll see your name/avatar in the header and By <name> on every new todo.

Step 9: Publish


pnpm upload
The script runs pnpm build (which lints, tests, and builds), then domo publish from the build/ folder. The new build becomes the active design. Anyone instantiating the app from the Asset Library picks it up.

Next steps


  • Run da generate component or da generate reducer to scaffold new slices that follow the same createAppSlice pattern.
  • Add a UserPreferences collection with a limitToOwner filter to persist per-user theme or default filter — see AppDB filters.
  • Layer dev/qa/prod id + proxyId values with da manifest and src/manifestOverrides.json instead of hand-editing.
  • Continue with AI Book Recommender or Mapbox World Map.