9.6 KiB

React Patterns

State Management

Local State with useState

import { useState } from "react"

function Counter() {
  const [count, setCount] = useState(0)
  
  return (
    <box flexDirection="row" gap={2}>
      <text>Count: {count}</text>
      <box border onMouseDown={() => setCount(c => c - 1)}>
        <text>-</text>
      </box>
      <box border onMouseDown={() => setCount(c => c + 1)}>
        <text>+</text>
      </box>
    </box>
  )
}

Complex State with useReducer

import { useReducer } from "react"

type State = {
  items: string[]
  selectedIndex: number
}

type Action =
  | { type: "ADD_ITEM"; item: string }
  | { type: "REMOVE_ITEM"; index: number }
  | { type: "SELECT"; index: number }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "ADD_ITEM":
      return { ...state, items: [...state.items, action.item] }
    case "REMOVE_ITEM":
      return {
        ...state,
        items: state.items.filter((_, i) => i !== action.index),
      }
    case "SELECT":
      return { ...state, selectedIndex: action.index }
  }
}

function ItemList() {
  const [state, dispatch] = useReducer(reducer, {
    items: [],
    selectedIndex: 0,
  })
  
  // Use state and dispatch...
}

Context for Global State

import { createContext, useContext, useState, ReactNode } from "react"

type Theme = "dark" | "light"

const ThemeContext = createContext<{
  theme: Theme
  setTheme: (theme: Theme) => void
} | null>(null)

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("dark")
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) throw new Error("useTheme must be used within ThemeProvider")
  return context
}

// Usage
function App() {
  return (
    <ThemeProvider>
      <ThemedBox />
    </ThemeProvider>
  )
}

function ThemedBox() {
  const { theme } = useTheme()
  return (
    <box backgroundColor={theme === "dark" ? "#1a1a2e" : "#f0f0f0"}>
      <text fg={theme === "dark" ? "#fff" : "#000"}>
        Current theme: {theme}
      </text>
    </box>
  )
}

Focus Management

Focus State

import { useState } from "react"
import { useKeyboard } from "@opentui/react"

function FocusableForm() {
  const [focusIndex, setFocusIndex] = useState(0)
  const fields = ["name", "email", "message"]
  
  useKeyboard((key) => {
    if (key.name === "tab") {
      setFocusIndex(i => (i + 1) % fields.length)
    }
    if (key.shift && key.name === "tab") {
      setFocusIndex(i => (i - 1 + fields.length) % fields.length)
    }
  })
  
  return (
    <box flexDirection="column" gap={1}>
      {fields.map((field, i) => (
        <input
          key={field}
          placeholder={`Enter ${field}...`}
          focused={i === focusIndex}
        />
      ))}
    </box>
  )
}

Ref-based Focus

import { useRef, useEffect } from "react"

function AutoFocusInput() {
  const inputRef = useRef<any>(null)
  
  useEffect(() => {
    // Focus on mount
    inputRef.current?.focus()
  }, [])
  
  return <input ref={inputRef} placeholder="Auto-focused" />
}

Keyboard Navigation

Global Shortcuts

import { useKeyboard } from "@opentui/react"

function App() {
  useKeyboard((key) => {
    // Quit on Escape or Ctrl+C
    if (key.name === "escape" || (key.ctrl && key.name === "c")) {
      process.exit(0)
    }
    
    // Toggle help on ?
    if (key.name === "?" || (key.shift && key.name === "/")) {
      setShowHelp(h => !h)
    }
    
    // Vim-style navigation
    if (key.name === "j") moveDown()
    if (key.name === "k") moveUp()
  })
  
  return <box>{/* ... */}</box>
}

Component-level Shortcuts

function Editor() {
  const [mode, setMode] = useState<"normal" | "insert">("normal")
  
  useKeyboard((key) => {
    if (mode === "normal") {
      if (key.name === "i") setMode("insert")
      if (key.name === "escape") setMode("normal")
    } else {
      if (key.name === "escape") setMode("normal")
      // Handle text input in insert mode
    }
  })
  
  return (
    <box>
      <text>Mode: {mode}</text>
      <textarea focused={mode === "insert"} />
    </box>
  )
}

Form Handling

Controlled Inputs

import { useState } from "react"

function LoginForm() {
  const [username, setUsername] = useState("")
  const [password, setPassword] = useState("")
  
  const handleSubmit = () => {
    console.log("Login:", { username, password })
  }
  
  return (
    <box flexDirection="column" gap={1} padding={2} border>
      <text>Login</text>
      
      <box flexDirection="row" gap={1}>
        <text>Username:</text>
        <input
          value={username}
          onChange={setUsername}
          width={20}
        />
      </box>
      
      <box flexDirection="row" gap={1}>
        <text>Password:</text>
        <input
          value={password}
          onChange={setPassword}
          width={20}
        />
      </box>
      
      <box border onMouseDown={handleSubmit}>
        <text>Submit</text>
      </box>
    </box>
  )
}

Form Validation

function ValidatedForm() {
  const [email, setEmail] = useState("")
  const [error, setError] = useState("")
  
  const validateEmail = (value: string) => {
    if (!value.includes("@")) {
      setError("Invalid email address")
    } else {
      setError("")
    }
    setEmail(value)
  }
  
  return (
    <box flexDirection="column" gap={1}>
      <input
        value={email}
        onChange={validateEmail}
        placeholder="Email"
      />
      {error && <text fg="red">{error}</text>}
    </box>
  )
}

Responsive Design

Terminal-size Responsive

import { useTerminalDimensions } from "@opentui/react"

function ResponsiveLayout() {
  const { width } = useTerminalDimensions()
  
  // Stack vertically on narrow terminals
  const isNarrow = width < 80
  
  return (
    <box flexDirection={isNarrow ? "column" : "row"}>
      <box flexGrow={isNarrow ? 0 : 1} height={isNarrow ? 10 : "100%"}>
        <text>Sidebar</text>
      </box>
      <box flexGrow={1}>
        <text>Main Content</text>
      </box>
    </box>
  )
}

Dynamic Layouts

function DynamicGrid({ items }: { items: string[] }) {
  const { width } = useTerminalDimensions()
  const columns = Math.max(1, Math.floor(width / 20))
  
  return (
    <box flexDirection="row" flexWrap="wrap">
      {items.map((item, i) => (
        <box key={i} width={`${100 / columns}%`} padding={1}>
          <text>{item}</text>
        </box>
      ))}
    </box>
  )
}

Async Data Loading

Loading States

import { useState, useEffect } from "react"

function DataDisplay() {
  const [data, setData] = useState<string[] | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  
  useEffect(() => {
    async function load() {
      try {
        const response = await fetch("https://api.example.com/data")
        const json = await response.json()
        setData(json.items)
      } catch (e) {
        setError(e instanceof Error ? e.message : "Unknown error")
      } finally {
        setLoading(false)
      }
    }
    load()
  }, [])
  
  if (loading) {
    return <text>Loading...</text>
  }
  
  if (error) {
    return <text fg="red">Error: {error}</text>
  }
  
  return (
    <box flexDirection="column">
      {data?.map((item, i) => (
        <text key={i}>{item}</text>
      ))}
    </box>
  )
}

Animation Patterns

Simple Animations

import { useState, useEffect } from "react"
import { useTimeline } from "@opentui/react"

function ProgressBar() {
  const [progress, setProgress] = useState(0)
  
  const timeline = useTimeline({ duration: 3000 })
  
  useEffect(() => {
    timeline.add(
      { value: 0 },
      {
        value: 100,
        duration: 3000,
        ease: "linear",
        onUpdate: (anim) => {
          setProgress(Math.round(anim.targets[0].value))
        },
      }
    )
  }, [])
  
  return (
    <box flexDirection="column" gap={1}>
      <text>Progress: {progress}%</text>
      <box width={50} height={1} backgroundColor="#333">
        <box
          width={`${progress}%`}
          height={1}
          backgroundColor="#00ff00"
        />
      </box>
    </box>
  )
}

Interval-based Updates

function Clock() {
  const [time, setTime] = useState(new Date())
  
  useEffect(() => {
    const interval = setInterval(() => {
      setTime(new Date())
    }, 1000)
    
    return () => clearInterval(interval)
  }, [])
  
  return <text>{time.toLocaleTimeString()}</text>
}

Component Composition

Render Props

function Focusable({ 
  children 
}: { 
  children: (focused: boolean) => React.ReactNode 
}) {
  const [focused, setFocused] = useState(false)
  
  return (
    <box
      onMouseDown={() => setFocused(true)}
      onMouseUp={() => setFocused(false)}
    >
      {children(focused)}
    </box>
  )
}

// Usage
<Focusable>
  {(focused) => (
    <text fg={focused ? "#00ff00" : "#ffffff"}>
      {focused ? "Focused!" : "Click me"}
    </text>
  )}
</Focusable>

Higher-Order Components

function withBorder<P extends object>(
  Component: React.ComponentType<P>,
  borderStyle: string = "single"
) {
  return function BorderedComponent(props: P) {
    return (
      <box border borderStyle={borderStyle} padding={1}>
        <Component {...props} />
      </box>
    )
  }
}

// Usage
const BorderedText = withBorder(({ content }: { content: string }) => (
  <text>{content}</text>
))

<BorderedText content="Hello!" />