React Setup Guide

View Source

This guide covers setting up a full-stack Phoenix + React + TypeScript application with AshTypescript.

Quick Setup

Use the React framework installer for automated setup:

mix igniter.install ash_typescript --framework react

This command automatically sets up:

  • 📦 Package.json with React 19 & TypeScript
  • ⚛️ React components with welcome page and documentation
  • 🎨 Tailwind CSS integration with modern styling
  • 🔧 Build configuration with esbuild and TypeScript compilation
  • 📄 Templates with proper script loading and syntax highlighting
  • 🌐 Getting started guide accessible at /ash-typescript in your Phoenix app

What Gets Created

Frontend Structure

assets/
 js/
    app.tsx              # React entry point
    ash_rpc.ts           # Generated TypeScript types
    components/
        Welcome.tsx      # Example component
 css/
    app.css              # Tailwind styles
 package.json             # Dependencies

Welcome Page

After running your Phoenix server, visit:

http://localhost:4000/ash-typescript

The welcome page includes:

  • Step-by-step setup instructions
  • Code examples with syntax highlighting
  • Links to documentation and demo projects
  • Type-safe RPC function examples

Manual React Setup

If you prefer manual setup or need to customize:

1. Install Dependencies

cd assets
npm install --save react react-dom
npm install --save-dev @types/react @types/react-dom typescript

2. Configure TypeScript

Create assets/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["js/**/*"],
  "exclude": ["node_modules"]
}

3. Create React Entry Point

Create assets/js/app.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './components/App';

const root = document.getElementById('root');
if (root) {
  ReactDOM.createRoot(root).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
}

4. Update Phoenix Template

In your lib/my_app_web/components/layouts/root.html.heex:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

5. Configure esbuild

Update config/config.exs:

config :esbuild,
  version: "0.17.11",
  default: [
    args: ~w(
      js/app.tsx
      --bundle
      --target=es2020
      --outdir=../priv/static/assets
      --external:/fonts/*
      --external:/images/*
    ),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

Using AshTypescript with React

Basic Component Example

import React, { useEffect, useState } from 'react';
import { listTodos, createTodo, type Todo } from '../ash_rpc';

export function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

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

  async function loadTodos() {
    setLoading(true);
    const result = await listTodos({
      fields: ["id", "title", "completed"]
    });

    if (result.success) {
      setTodos(result.data.results);
      setError(null);
    } else {
      setError(result.errors.map(e => e.message).join(', '));
    }
    setLoading(false);
  }

  async function handleCreate(title: string) {
    const result = await createTodo({
      fields: ["id", "title", "completed"],
      input: { title }
    });

    if (result.success) {
      setTodos([...todos, result.data]);
    } else {
      setError(result.errors.map(e => e.message).join(', '));
    }
  }

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
      <h1>Todos</h1>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.title} - {todo.completed ? '✓' : '○'}
          </li>
        ))}
      </ul>
      <button onClick={() => handleCreate('New Todo')}>
        Add Todo
      </button>
    </div>
  );
}

With TanStack Query

For better data fetching, use TanStack Query (React Query):

npm install @tanstack/react-query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listTodos, createTodo } from '../ash_rpc';

export function TodoListWithQuery() {
  const queryClient = useQueryClient();

  const { data, isLoading, error } = useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const result = await listTodos({
        fields: ["id", "title", "completed"]
      });
      if (!result.success) {
        throw new Error(result.errors.map(e => e.message).join(', '));
      }
      return result.data.results;
    }
  });

  const createMutation = useMutation({
    mutationFn: async (title: string) => {
      const result = await createTodo({
        fields: ["id", "title", "completed"],
        input: { title }
      });
      if (!result.success) {
        throw new Error(result.errors.map(e => e.message).join(', '));
      }
      return result.data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>Todos</h1>
      <ul>
        {data?.map(todo => (
          <li key={todo.id}>
            {todo.title} - {todo.completed ? '✓' : '○'}
          </li>
        ))}
      </ul>
      <button onClick={() => createMutation.mutate('New Todo')}>
        Add Todo
      </button>
    </div>
  );
}

Example Repository

Check out the AshTypescript Demo by Christian Alexander for a complete example featuring:

  • Complete Phoenix + React + TypeScript integration
  • TanStack Query for data fetching
  • TanStack Table for data display
  • Best practices and patterns

Adding Tailwind CSS

1. Install Tailwind

cd assets
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init

2. Configure Tailwind

Update assets/tailwind.config.js:

module.exports = {
  content: [
    './js/**/*.{js,jsx,ts,tsx}',
    '../lib/*_web/**/*.*ex'
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

3. Add Tailwind Directives

In assets/css/app.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

4. Configure PostCSS

Create assets/postcss.config.js:

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  }
}

Development Workflow

1. Start Phoenix Server

mix phx.server

This automatically:

  • Compiles TypeScript
  • Watches for file changes
  • Hot-reloads the browser

2. Generate Types

Whenever you change resources or actions:

mix ash.codegen

3. Type Check

Add a script to package.json:

{
  "scripts": {
    "typecheck": "tsc --noEmit"
  }
}

Run type checking:

npm run typecheck

CSRF Protection

When using session-based authentication, use CSRF headers:

import { listTodos, buildCSRFHeaders } from '../ash_rpc';

const result = await listTodos({
  fields: ["id", "title"],
  headers: buildCSRFHeaders()
});

The buildCSRFHeaders() function automatically reads the CSRF token from the meta tag in your layout.

Next Steps