Fullstack dApp

Build a complete decentralized application from scratch—a Todo List dApp with an Anchor program backend and React frontend. This capstone integrates everything you've learned.

Project Overview

Text
┌─────────────────────────────────────────────────────────────────┐
│                    Fullstack Todo dApp                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  📝 My On-Chain Todo List                  [Connect] 🔗 │   │
│  │                                                         │   │
│  │  ┌─────────────────────────────────────────────────┐   │   │
│  │  │ [ ] Learn Anchor framework          [Edit][🗑️]  │   │   │
│  │  │ [] Deploy first Solana program     [Edit][🗑️]  │   │   │
│  │  │ [ ] Build fullstack dApp            [Edit][🗑️]  │   │   │
│  │  └─────────────────────────────────────────────────┘   │   │
│  │                                                         │   │
│  │  ┌─────────────────────────────────────────────────┐   │   │
│  │  │ Add new todo: [________________________] [Add]  │   │   │
│  │  └─────────────────────────────────────────────────┘   │   │
│  │                                                         │   │
│  │  Stats: 3 todos • 1 completed • 2 pending              │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  Architecture:                                                  │
│  ┌──────────┐      ┌──────────┐      ┌──────────────────┐     │
│  │  React   │ ───▶ │  Anchor  │ ───▶ │  Solana Program  │     │
│  │ Frontend │      │  Client  │      │  (On-Chain)      │     │
│  └──────────┘      └──────────┘      └──────────────────┘     │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Project Structure

Text
todo-dapp/
├── programs/
│   └── todo/
│       ├── Cargo.toml
│       └── src/
│           └── lib.rs          # Anchor program
├── app/                        # Next.js frontend
│   ├── components/
│   │   ├── TodoList.tsx
│   │   ├── TodoItem.tsx
│   │   ├── AddTodo.tsx
│   │   └── WalletProvider.tsx
│   ├── hooks/
│   │   └── useTodo.ts
│   ├── idl/
│   │   └── todo.json
│   └── app/
│       ├── layout.tsx
│       └── page.tsx
├── tests/
│   └── todo.ts
├── Anchor.toml
└── package.json

Part 1: Anchor Program

Program Setup

Bash
# Initialize Anchor project
anchor init todo-dapp
cd todo-dapp

# Build to get program ID
anchor build
solana address -k target/deploy/todo-keypair.json

# Update lib.rs with program ID
# Then rebuild
anchor build

Program Code

Rust
// programs/todo/src/lib.rs
use anchor_lang::prelude::*;

declare_id!("YOUR_PROGRAM_ID_HERE");

#[program]
pub mod todo {
    use super::*;

    /// Initialize a new todo list for the user
    pub fn initialize_list(ctx: Context<InitializeList>) -> Result<()> {
        let todo_list = &mut ctx.accounts.todo_list;
        todo_list.authority = ctx.accounts.authority.key();
        todo_list.count = 0;
        todo_list.bump = ctx.bumps.todo_list;

        msg!("Todo list initialized for: {}", ctx.accounts.authority.key());
        Ok(())
    }

    /// Add a new todo item
    pub fn add_todo(ctx: Context<AddTodo>, content: String) -> Result<()> {
        require!(content.len() <= 280, TodoError::ContentTooLong);

        let todo_list = &mut ctx.accounts.todo_list;
        let todo_item = &mut ctx.accounts.todo_item;

        todo_item.id = todo_list.count;
        todo_item.authority = ctx.accounts.authority.key();
        todo_item.content = content.clone();
        todo_item.completed = false;
        todo_item.created_at = Clock::get()?.unix_timestamp;
        todo_item.bump = ctx.bumps.todo_item;

        todo_list.count = todo_list.count.checked_add(1)
            .ok_or(TodoError::CounterOverflow)?;

        msg!("Todo #{} added: {}", todo_item.id, content);
        Ok(())
    }

    /// Toggle todo completion status
    pub fn toggle_todo(ctx: Context<UpdateTodo>) -> Result<()> {
        let todo_item = &mut ctx.accounts.todo_item;
        todo_item.completed = !todo_item.completed;

        msg!(
            "Todo #{} toggled to: {}",
            todo_item.id,
            if todo_item.completed { "completed" } else { "pending" }
        );
        Ok(())
    }

    /// Update todo content
    pub fn update_todo(ctx: Context<UpdateTodo>, content: String) -> Result<()> {
        require!(content.len() <= 280, TodoError::ContentTooLong);

        let todo_item = &mut ctx.accounts.todo_item;
        todo_item.content = content.clone();

        msg!("Todo #{} updated: {}", todo_item.id, content);
        Ok(())
    }

    /// Delete a todo item
    pub fn delete_todo(ctx: Context<DeleteTodo>) -> Result<()> {
        msg!("Todo #{} deleted", ctx.accounts.todo_item.id);
        Ok(())
    }
}

// ============ Accounts ============

#[derive(Accounts)]
pub struct InitializeList<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,

    #[account(
        init,
        payer = authority,
        space = TodoList::SIZE,
        seeds = [b"todo-list", authority.key().as_ref()],
        bump
    )]
    pub todo_list: Account<'info, TodoList>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
#[instruction(content: String)]
pub struct AddTodo<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,

    #[account(
        mut,
        seeds = [b"todo-list", authority.key().as_ref()],
        bump = todo_list.bump,
        constraint = todo_list.authority == authority.key() @ TodoError::Unauthorized
    )]
    pub todo_list: Account<'info, TodoList>,

    #[account(
        init,
        payer = authority,
        space = TodoItem::SIZE,
        seeds = [
            b"todo-item",
            authority.key().as_ref(),
            &todo_list.count.to_le_bytes()
        ],
        bump
    )]
    pub todo_item: Account<'info, TodoItem>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct UpdateTodo<'info> {
    #[account(
        constraint = todo_item.authority == authority.key() @ TodoError::Unauthorized
    )]
    pub authority: Signer<'info>,

    #[account(
        mut,
        seeds = [
            b"todo-item",
            authority.key().as_ref(),
            &todo_item.id.to_le_bytes()
        ],
        bump = todo_item.bump
    )]
    pub todo_item: Account<'info, TodoItem>,
}

#[derive(Accounts)]
pub struct DeleteTodo<'info> {
    #[account(
        mut,
        constraint = todo_item.authority == authority.key() @ TodoError::Unauthorized
    )]
    pub authority: Signer<'info>,

    #[account(
        mut,
        close = authority,
        seeds = [
            b"todo-item",
            authority.key().as_ref(),
            &todo_item.id.to_le_bytes()
        ],
        bump = todo_item.bump
    )]
    pub todo_item: Account<'info, TodoItem>,
}

// ============ State ============

#[account]
pub struct TodoList {
    pub authority: Pubkey,
    pub count: u64,
    pub bump: u8,
}

impl TodoList {
    pub const SIZE: usize = 8 + // discriminator
        32 +  // authority
        8 +   // count
        1;    // bump
}

#[account]
pub struct TodoItem {
    pub id: u64,
    pub authority: Pubkey,
    pub content: String,
    pub completed: bool,
    pub created_at: i64,
    pub bump: u8,
}

impl TodoItem {
    pub const SIZE: usize = 8 +  // discriminator
        8 +   // id
        32 +  // authority
        4 + 280 + // content (string prefix + max chars)
        1 +   // completed
        8 +   // created_at
        1;    // bump
}

// ============ Errors ============

#[error_code]
pub enum TodoError {
    #[msg("Content exceeds 280 characters")]
    ContentTooLong,
    #[msg("Counter overflow")]
    CounterOverflow,
    #[msg("Unauthorized")]
    Unauthorized,
}

Part 2: Program Tests

TypeScript
// tests/todo.ts
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Todo } from "../target/types/todo";
import { expect } from "chai";

describe("todo", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.Todo as Program<Todo>;
  const authority = provider.wallet;

  // Derive PDAs
  const [todoListPda] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("todo-list"), authority.publicKey.toBuffer()],
    program.programId,
  );

  const getTodoItemPda = (index: number) => {
    return anchor.web3.PublicKey.findProgramAddressSync(
      [
        Buffer.from("todo-item"),
        authority.publicKey.toBuffer(),
        new anchor.BN(index).toArrayLike(Buffer, "le", 8),
      ],
      program.programId,
    )[0];
  };

  it("Initializes todo list", async () => {
    await program.methods
      .initializeList()
      .accounts({
        authority: authority.publicKey,
        todoList: todoListPda,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .rpc();

    const todoList = await program.account.todoList.fetch(todoListPda);
    expect(todoList.authority.toBase58()).to.equal(
      authority.publicKey.toBase58(),
    );
    expect(todoList.count.toNumber()).to.equal(0);
  });

  it("Adds a todo item", async () => {
    const content = "Learn Anchor framework";
    const todoItemPda = getTodoItemPda(0);

    await program.methods
      .addTodo(content)
      .accounts({
        authority: authority.publicKey,
        todoList: todoListPda,
        todoItem: todoItemPda,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .rpc();

    const todoItem = await program.account.todoItem.fetch(todoItemPda);
    expect(todoItem.content).to.equal(content);
    expect(todoItem.completed).to.equal(false);
    expect(todoItem.id.toNumber()).to.equal(0);

    const todoList = await program.account.todoList.fetch(todoListPda);
    expect(todoList.count.toNumber()).to.equal(1);
  });

  it("Adds multiple todos", async () => {
    const contents = ["Build dApp", "Deploy to mainnet"];

    for (let i = 0; i < contents.length; i++) {
      const todoList = await program.account.todoList.fetch(todoListPda);
      const todoItemPda = getTodoItemPda(todoList.count.toNumber());

      await program.methods
        .addTodo(contents[i])
        .accounts({
          authority: authority.publicKey,
          todoList: todoListPda,
          todoItem: todoItemPda,
          systemProgram: anchor.web3.SystemProgram.programId,
        })
        .rpc();
    }

    const todoList = await program.account.todoList.fetch(todoListPda);
    expect(todoList.count.toNumber()).to.equal(3);
  });

  it("Toggles todo completion", async () => {
    const todoItemPda = getTodoItemPda(0);

    await program.methods
      .toggleTodo()
      .accounts({
        authority: authority.publicKey,
        todoItem: todoItemPda,
      })
      .rpc();

    let todoItem = await program.account.todoItem.fetch(todoItemPda);
    expect(todoItem.completed).to.equal(true);

    // Toggle back
    await program.methods
      .toggleTodo()
      .accounts({
        authority: authority.publicKey,
        todoItem: todoItemPda,
      })
      .rpc();

    todoItem = await program.account.todoItem.fetch(todoItemPda);
    expect(todoItem.completed).to.equal(false);
  });

  it("Updates todo content", async () => {
    const todoItemPda = getTodoItemPda(0);
    const newContent = "Master Anchor framework";

    await program.methods
      .updateTodo(newContent)
      .accounts({
        authority: authority.publicKey,
        todoItem: todoItemPda,
      })
      .rpc();

    const todoItem = await program.account.todoItem.fetch(todoItemPda);
    expect(todoItem.content).to.equal(newContent);
  });

  it("Deletes a todo", async () => {
    const todoItemPda = getTodoItemPda(2);

    const balanceBefore = await provider.connection.getBalance(
      authority.publicKey,
    );

    await program.methods
      .deleteTodo()
      .accounts({
        authority: authority.publicKey,
        todoItem: todoItemPda,
      })
      .rpc();

    // Account should be closed
    const account = await provider.connection.getAccountInfo(todoItemPda);
    expect(account).to.be.null;

    // Rent should be returned
    const balanceAfter = await provider.connection.getBalance(
      authority.publicKey,
    );
    expect(balanceAfter).to.be.greaterThan(balanceBefore - 10000);
  });

  it("Fails with content too long", async () => {
    const todoList = await program.account.todoList.fetch(todoListPda);
    const todoItemPda = getTodoItemPda(todoList.count.toNumber());
    const longContent = "a".repeat(300);

    try {
      await program.methods
        .addTodo(longContent)
        .accounts({
          authority: authority.publicKey,
          todoList: todoListPda,
          todoItem: todoItemPda,
          systemProgram: anchor.web3.SystemProgram.programId,
        })
        .rpc();

      expect.fail("Should have thrown");
    } catch (err: any) {
      expect(err.error.errorCode.code).to.equal("ContentTooLong");
    }
  });
});

Part 3: Frontend Setup

Bash
# Create Next.js app in the project
cd todo-dapp
bunx create-next-app@latest app --typescript --tailwind --app --no-eslint

# Install dependencies
cd app
bun add @solana/web3.js @coral-xyz/anchor @solana/wallet-adapter-react \
  @solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets \
  @tanstack/react-query

# Copy IDL
mkdir -p src/idl
cp ../target/idl/todo.json src/idl/

Part 4: Frontend Hook

TypeScript
// hooks/useTodo.ts
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { Program, AnchorProvider, BN, setProvider } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import idl from "@/idl/todo.json";

const PROGRAM_ID = new PublicKey("YOUR_PROGRAM_ID_HERE");

interface TodoItem {
  publicKey: PublicKey;
  id: number;
  content: string;
  completed: boolean;
  createdAt: number;
}

interface TodoList {
  count: number;
}

export function useTodo() {
  const { connection } = useConnection();
  const wallet = useWallet();
  const queryClient = useQueryClient();

  const program = useMemo(() => {
    if (!wallet.publicKey) return null;
    const provider = new AnchorProvider(connection, wallet as any, {
      commitment: "confirmed",
    });
    setProvider(provider);
    return new Program(idl as any, PROGRAM_ID, provider);
  }, [connection, wallet]);

  const todoListPda = useMemo(() => {
    if (!wallet.publicKey) return null;
    const [pda] = PublicKey.findProgramAddressSync(
      [Buffer.from("todo-list"), wallet.publicKey.toBuffer()],
      PROGRAM_ID,
    );
    return pda;
  }, [wallet.publicKey]);

  const getTodoItemPda = (index: number) => {
    if (!wallet.publicKey) return null;
    const [pda] = PublicKey.findProgramAddressSync(
      [
        Buffer.from("todo-item"),
        wallet.publicKey.toBuffer(),
        new BN(index).toArrayLike(Buffer, "le", 8),
      ],
      PROGRAM_ID,
    );
    return pda;
  };

  // Fetch todo list
  const { data: todoList, isLoading: isLoadingList } = useQuery({
    queryKey: ["todoList", todoListPda?.toBase58()],
    queryFn: async (): Promise<TodoList | null> => {
      if (!program || !todoListPda) return null;
      try {
        const list = await program.account.todoList.fetch(todoListPda);
        return { count: list.count.toNumber() };
      } catch {
        return null;
      }
    },
    enabled: !!program && !!todoListPda,
  });

  // Fetch all todo items
  const { data: todos, isLoading: isLoadingTodos } = useQuery({
    queryKey: ["todos", wallet.publicKey?.toBase58()],
    queryFn: async (): Promise<TodoItem[]> => {
      if (!program || !wallet.publicKey) return [];

      const accounts = await program.account.todoItem.all([
        {
          memcmp: {
            offset: 8 + 8, // Skip discriminator + id
            bytes: wallet.publicKey.toBase58(),
          },
        },
      ]);

      return accounts
        .map((acc) => ({
          publicKey: acc.publicKey,
          id: acc.account.id.toNumber(),
          content: acc.account.content,
          completed: acc.account.completed,
          createdAt: acc.account.createdAt.toNumber(),
        }))
        .sort((a, b) => a.id - b.id);
    },
    enabled: !!program && !!wallet.publicKey,
    refetchInterval: 10000,
  });

  // Initialize list
  const initializeMutation = useMutation({
    mutationFn: async () => {
      if (!program || !wallet.publicKey || !todoListPda) {
        throw new Error("Not connected");
      }

      return program.methods
        .initializeList()
        .accounts({
          authority: wallet.publicKey,
          todoList: todoListPda,
        })
        .rpc();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todoList"] });
    },
  });

  // Add todo
  const addTodoMutation = useMutation({
    mutationFn: async (content: string) => {
      if (!program || !wallet.publicKey || !todoListPda || !todoList) {
        throw new Error("Not connected");
      }

      const todoItemPda = getTodoItemPda(todoList.count);
      if (!todoItemPda) throw new Error("Invalid PDA");

      return program.methods
        .addTodo(content)
        .accounts({
          authority: wallet.publicKey,
          todoList: todoListPda,
          todoItem: todoItemPda,
        })
        .rpc();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todoList"] });
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  // Toggle todo
  const toggleTodoMutation = useMutation({
    mutationFn: async (todo: TodoItem) => {
      if (!program || !wallet.publicKey) {
        throw new Error("Not connected");
      }

      return program.methods
        .toggleTodo()
        .accounts({
          authority: wallet.publicKey,
          todoItem: todo.publicKey,
        })
        .rpc();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  // Update todo
  const updateTodoMutation = useMutation({
    mutationFn: async ({
      todo,
      content,
    }: {
      todo: TodoItem;
      content: string;
    }) => {
      if (!program || !wallet.publicKey) {
        throw new Error("Not connected");
      }

      return program.methods
        .updateTodo(content)
        .accounts({
          authority: wallet.publicKey,
          todoItem: todo.publicKey,
        })
        .rpc();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  // Delete todo
  const deleteTodoMutation = useMutation({
    mutationFn: async (todo: TodoItem) => {
      if (!program || !wallet.publicKey) {
        throw new Error("Not connected");
      }

      return program.methods
        .deleteTodo()
        .accounts({
          authority: wallet.publicKey,
          todoItem: todo.publicKey,
        })
        .rpc();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  return {
    todoList,
    todos: todos || [],
    isLoading: isLoadingList || isLoadingTodos,
    isInitialized: !!todoList,
    initialize: initializeMutation.mutateAsync,
    addTodo: addTodoMutation.mutateAsync,
    toggleTodo: toggleTodoMutation.mutateAsync,
    updateTodo: updateTodoMutation.mutateAsync,
    deleteTodo: deleteTodoMutation.mutateAsync,
    isInitializing: initializeMutation.isPending,
    isAdding: addTodoMutation.isPending,
  };
}

Part 5: Frontend Components

TypeScript
// components/TodoList.tsx
"use client";

import { useTodo } from "@/hooks/useTodo";
import { TodoItem } from "./TodoItem";
import { AddTodo } from "./AddTodo";

export function TodoList() {
  const {
    todos,
    isLoading,
    isInitialized,
    initialize,
    addTodo,
    toggleTodo,
    updateTodo,
    deleteTodo,
    isInitializing,
    isAdding,
  } = useTodo();

  if (isLoading) {
    return (
      <div className="text-center py-12">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
      </div>
    );
  }

  if (!isInitialized) {
    return (
      <div className="text-center py-12">
        <h3 className="text-lg font-medium mb-4">Create Your Todo List</h3>
        <p className="text-gray-500 mb-6">
          Initialize your on-chain todo list to get started
        </p>
        <button
          onClick={() => initialize()}
          disabled={isInitializing}
          className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium
                     hover:bg-blue-700 disabled:bg-gray-400"
        >
          {isInitializing ? "Creating..." : "Create Todo List"}
        </button>
      </div>
    );
  }

  const completedCount = todos.filter((t) => t.completed).length;

  return (
    <div className="space-y-6">
      <AddTodo onAdd={addTodo} isAdding={isAdding} />

      <div className="space-y-2">
        {todos.length === 0 ? (
          <p className="text-center text-gray-500 py-8">
            No todos yet. Add your first one above!
          </p>
        ) : (
          todos.map((todo) => (
            <TodoItem
              key={todo.publicKey.toBase58()}
              todo={todo}
              onToggle={() => toggleTodo(todo)}
              onUpdate={(content) => updateTodo({ todo, content })}
              onDelete={() => deleteTodo(todo)}
            />
          ))
        )}
      </div>

      {todos.length > 0 && (
        <div className="text-sm text-gray-500 text-center">
          {todos.length} todos • {completedCount} completed •{" "}
          {todos.length - completedCount} pending
        </div>
      )}
    </div>
  );
}
TypeScript
// components/TodoItem.tsx
"use client";

import { useState } from "react";
import { Pencil, Trash2, Check, X } from "lucide-react";

interface TodoItemProps {
  todo: {
    id: number;
    content: string;
    completed: boolean;
    createdAt: number;
  };
  onToggle: () => void;
  onUpdate: (content: string) => void;
  onDelete: () => void;
}

export function TodoItem({ todo, onToggle, onUpdate, onDelete }: TodoItemProps) {
  const [isEditing, setIsEditing] = useState(false);
  const [editContent, setEditContent] = useState(todo.content);

  const handleSave = () => {
    if (editContent.trim() && editContent !== todo.content) {
      onUpdate(editContent);
    }
    setIsEditing(false);
  };

  const handleCancel = () => {
    setEditContent(todo.content);
    setIsEditing(false);
  };

  return (
    <div className="flex items-center gap-3 p-4 bg-white dark:bg-gray-900 rounded-lg border dark:border-gray-800">
      {/* Checkbox */}
      <button
        onClick={onToggle}
        className={`w-6 h-6 rounded-full border-2 flex items-center justify-center
                   transition-colors ${
                     todo.completed
                       ? "bg-green-500 border-green-500 text-white"
                       : "border-gray-300 hover:border-green-400"
                   }`}
      >
        {todo.completed && <Check className="w-4 h-4" />}
      </button>

      {/* Content */}
      <div className="flex-1">
        {isEditing ? (
          <input
            type="text"
            value={editContent}
            onChange={(e) => setEditContent(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Enter") handleSave();
              if (e.key === "Escape") handleCancel();
            }}
            className="w-full px-2 py-1 border rounded focus:ring-2 focus:ring-blue-500
                       dark:bg-gray-800 dark:border-gray-700"
            autoFocus
          />
        ) : (
          <span
            className={`${
              todo.completed ? "line-through text-gray-400" : ""
            }`}
          >
            {todo.content}
          </span>
        )}
      </div>

      {/* Actions */}
      <div className="flex items-center gap-2">
        {isEditing ? (
          <>
            <button
              onClick={handleSave}
              className="p-2 text-green-600 hover:bg-green-50 rounded-lg"
            >
              <Check className="w-4 h-4" />
            </button>
            <button
              onClick={handleCancel}
              className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg"
            >
              <X className="w-4 h-4" />
            </button>
          </>
        ) : (
          <>
            <button
              onClick={() => setIsEditing(true)}
              className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
            >
              <Pencil className="w-4 h-4" />
            </button>
            <button
              onClick={onDelete}
              className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
            >
              <Trash2 className="w-4 h-4" />
            </button>
          </>
        )}
      </div>
    </div>
  );
}
TypeScript
// components/AddTodo.tsx
"use client";

import { useState } from "react";
import { Plus } from "lucide-react";

interface AddTodoProps {
  onAdd: (content: string) => Promise<void>;
  isAdding: boolean;
}

export function AddTodo({ onAdd, isAdding }: AddTodoProps) {
  const [content, setContent] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!content.trim()) return;

    try {
      await onAdd(content.trim());
      setContent("");
    } catch (err) {
      console.error("Failed to add todo:", err);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        type="text"
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="What needs to be done?"
        maxLength={280}
        className="flex-1 px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500
                   dark:bg-gray-900 dark:border-gray-700"
      />
      <button
        type="submit"
        disabled={!content.trim() || isAdding}
        className="px-4 py-3 bg-blue-600 text-white rounded-lg font-medium
                   hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed
                   flex items-center gap-2"
      >
        <Plus className="w-5 h-5" />
        {isAdding ? "Adding..." : "Add"}
      </button>
    </form>
  );
}

Deployment Checklist

Text
┌─────────────────────────────────────────────────────────────────┐
│                     Deployment Checklist                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Program:                                                       │
│  ☐ Update program ID in lib.rs                                 │
│  ☐ Run anchor build                                            │
│  ☐ Run anchor test                                             │
│  ☐ Deploy to devnet: anchor deploy --provider.cluster devnet   │
│  ☐ Verify on explorer                                          │
│                                                                 │
│  Frontend:                                                      │
│  ☐ Copy IDL to frontend                                        │
│  ☐ Update PROGRAM_ID in hooks                                  │
│  ☐ Configure network (devnet/mainnet)                          │
│  ☐ Test with wallet connection                                 │
│  ☐ Deploy to Vercel/Netlify                                    │
│                                                                 │
│  Production:                                                    │
│  ☐ Audit program code                                          │
│  ☐ Add rate limiting                                           │
│  ☐ Set up monitoring                                           │
│  ☐ Configure RPC endpoint                                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

What You'll Learn

  • Complete Anchor program development
  • Account management with PDAs
  • Frontend-program integration
  • React Query for data fetching
  • Transaction handling and confirmation
  • Testing strategies for Solana programs

Next: DeFi App - Build a decentralized exchange.