Skip to main content

Custom UI Development Guide

This guide walks you through creating custom user interfaces in Stack9 applications, from simple components to complex screens with data fetching, forms, and workflows.

Table of Contents

Overview

Stack9 provides three ways to build user interfaces:

  1. Configuration-driven screens - Define UI through JSON configuration
  2. Custom React components - Build completely custom UI with React
  3. Hybrid approach - Combine Stack9 components with custom logic

This guide focuses on the custom React component approach, showing you how to leverage Stack9's powerful hooks and components while building tailored experiences for your users.

Setting Up Your Development Environment

Project Structure

Stack9 applications follow this structure for custom UI development:

apps/stack9-frontend/
├── src/
│ ├── index.tsx # Main entry point and component registration
│ ├── components/ # Reusable custom components
│ ├── pages/ # Custom page components
│ ├── hooks/ # Custom React hooks
│ ├── services/ # API service wrappers
│ └── providers/ # Custom context providers
├── package.json
└── tsconfig.json

Required Dependencies

Ensure your package.json includes:

{
"dependencies": {
"@april9au/stack9-react": "^2.0.0",
"@april9au/stack9-ui": "^2.0.0",
"@april9au/stack9-sdk": "^2.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"antd": "^5.0.0"
}
}

TypeScript Configuration

Configure TypeScript for Stack9 development:

{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

Creating Custom Components

Basic Custom Component

Let's create a customer profile card component:

// src/components/CustomerProfileCard.tsx
import React from 'react';
import { Card, Avatar, Descriptions, Tag } from 'antd';
import { S9Tag } from '@april9au/stack9-ui';
import { UserOutlined } from '@ant-design/icons';

interface CustomerProfileCardProps {
customer: {
id: number;
name: string;
email: string;
phone?: string;
status: 'active' | 'inactive' | 'pending';
creditLimit?: number;
lastOrderDate?: string;
};
onEdit?: () => void;
onViewOrders?: () => void;
}

export const CustomerProfileCard: React.FC<CustomerProfileCardProps> = ({
customer,
onEdit,
onViewOrders
}) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'green';
case 'inactive': return 'red';
case 'pending': return 'orange';
default: return 'default';
}
};

return (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Avatar size={48} icon={<UserOutlined />}>
{customer.name[0]}
</Avatar>
<div>
<h3 style={{ margin: 0 }}>{customer.name}</h3>
<S9Tag color={getStatusColor(customer.status)}>
{customer.status.toUpperCase()}
</S9Tag>
</div>
</div>
}
actions={[
onEdit && <span onClick={onEdit}>Edit</span>,
onViewOrders && <span onClick={onViewOrders}>View Orders</span>
].filter(Boolean)}
>
<Descriptions column={1}>
<Descriptions.Item label="Email">{customer.email}</Descriptions.Item>
<Descriptions.Item label="Phone">{customer.phone || 'N/A'}</Descriptions.Item>
<Descriptions.Item label="Credit Limit">
${customer.creditLimit?.toLocaleString() || '0'}
</Descriptions.Item>
<Descriptions.Item label="Last Order">
{customer.lastOrderDate || 'No orders yet'}
</Descriptions.Item>
</Descriptions>
</Card>
);
};

Component with Data Fetching

Create a component that fetches its own data:

// src/components/CustomerOrders.tsx
import React, { useState, useEffect } from 'react';
import { S9Table, S9Tag } from '@april9au/stack9-ui';
import { useStack9 } from '@april9au/stack9-react';
import { message } from 'antd';

interface CustomerOrdersProps {
customerId: number;
}

export const CustomerOrders: React.FC<CustomerOrdersProps> = ({ customerId }) => {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(false);
const { entityService } = useStack9();

useEffect(() => {
fetchOrders();
}, [customerId]);

const fetchOrders = async () => {
setLoading(true);
try {
const result = await entityService.list('order', {
filter: { customer_id: customerId },
sort: 'created_at DESC',
limit: 20
});
setOrders(result.items);
} catch (error) {
message.error('Failed to load orders');
console.error(error);
} finally {
setLoading(false);
}
};

const columns = [
{
title: 'Order #',
dataIndex: 'orderNumber',
key: 'orderNumber',
},
{
title: 'Date',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleDateString()
},
{
title: 'Total',
dataIndex: 'total',
key: 'total',
render: (value: number) => `$${value.toLocaleString()}`
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<S9Tag color={status === 'completed' ? 'green' : 'orange'}>
{status.toUpperCase()}
</S9Tag>
)
}
];

return (
<S9Table
columns={columns}
dataSource={orders}
loading={loading}
rowKey="id"
pagination={{ pageSize: 10 }}
/>
);
};

Building a Custom Detail View

Let's create a complete custom detail view for a customer profile:

// src/pages/CustomerDetail/CustomerDetail.tsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
S9Page,
S9PageHeader,
S9PageContent,
S9Tabs,
S9Button,
S9EntityActions,
S9EntityWorkflowActions,
S9Row,
S9Col,
S9Fieldset
} from '@april9au/stack9-ui';
import {
useEntitySchema,
useEntityAttachments,
useEntityComments,
useEntityLogs,
useEntityTasks
} from '@april9au/stack9-react';
import { CustomerProfileCard } from '../../components/CustomerProfileCard';
import { CustomerOrders } from '../../components/CustomerOrders';
import { Spin, message } from 'antd';

const { TabPane } = S9Tabs;

export const CustomerDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const customerId = parseInt(id!, 10);

// Stack9 hooks for entity management
const { schema, isLoading: schemaLoading } = useEntitySchema('customer', customerId);
const { attachments, uploadAttachment } = useEntityAttachments('customer', customerId);
const { comments, addComment } = useEntityComments('customer', customerId);
const { logs } = useEntityLogs('customer', customerId);
const { tasks, createTask } = useEntityTasks('customer', customerId);

const [customer, setCustomer] = useState<any>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetchCustomer();
}, [customerId]);

const fetchCustomer = async () => {
try {
setLoading(true);
// In a real app, use entityService from useStack9()
const response = await fetch(`/api/customers/${customerId}`);
const data = await response.json();
setCustomer(data);
} catch (error) {
message.error('Failed to load customer details');
console.error(error);
} finally {
setLoading(false);
}
};

const handleEdit = () => {
navigate(`/customers/${customerId}/edit`);
};

const handleWorkflowTransition = async (transition: string) => {
try {
// Handle workflow transition
await fetch(`/api/customers/${customerId}/workflow`, {
method: 'POST',
body: JSON.stringify({ transition })
});
message.success('Status updated successfully');
fetchCustomer();
} catch (error) {
message.error('Failed to update status');
}
};

const handleAddTask = async () => {
await createTask({
title: 'Follow up with customer',
description: 'Contact customer for feedback',
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
assignee: 'current-user'
});
message.success('Task created');
};

if (loading || schemaLoading) {
return (
<S9Page>
<S9PageContent>
<Spin size="large" />
</S9PageContent>
</S9Page>
);
}

if (!customer) {
return (
<S9Page>
<S9PageContent>
<div>Customer not found</div>
</S9PageContent>
</S9Page>
);
}

return (
<S9Page>
<S9PageHeader
title={customer.name}
breadcrumb={[
{ label: 'Home', path: '/' },
{ label: 'Customers', path: '/customers' },
{ label: customer.name }
]}
extra={[
<S9EntityWorkflowActions
key="workflow"
entity={customer}
entityKey="customer"
currentState={customer.status}
availableTransitions={[
{ key: 'activate', label: 'Activate', nextState: 'active' },
{ key: 'deactivate', label: 'Deactivate', nextState: 'inactive' }
]}
onTransition={handleWorkflowTransition}
/>,
<S9EntityActions
key="actions"
entity={customer}
entityKey="customer"
onEdit={handleEdit}
permissions={{
canEdit: true,
canDelete: false // Protect from accidental deletion
}}
/>
]}
/>

<S9PageContent>
<S9Row gutter={[24, 24]}>
<S9Col xs={24} lg={8}>
<CustomerProfileCard
customer={customer}
onEdit={handleEdit}
onViewOrders={() => document.getElementById('orders-tab')?.click()}
/>
</S9Col>

<S9Col xs={24} lg={16}>
<S9Tabs defaultActiveKey="details" id="customer-tabs">
<TabPane tab="Details" key="details">
{schema && (
<S9Fieldset
context={{ id: customerId }}
schema={schema}
formFields={[
{ field: 'name', colSize: 12 },
{ field: 'email', colSize: 12 },
{ field: 'phone', colSize: 12 },
{ field: 'company', colSize: 12 },
{ field: 'address', colSize: 24 },
{ field: 'notes', colSize: 24 }
]}
mode="view"
/>
)}
</TabPane>

<TabPane tab="Orders" key="orders" id="orders-tab">
<CustomerOrders customerId={customerId} />
</TabPane>

<TabPane tab={`Tasks (${tasks?.length || 0})`} key="tasks">
<S9Button type="primary" onClick={handleAddTask} style={{ marginBottom: 16 }}>
Add Task
</S9Button>
{/* Render tasks list */}
<div>
{tasks?.map(task => (
<div key={task.id}>
<h4>{task.title}</h4>
<p>{task.description}</p>
</div>
))}
</div>
</TabPane>

<TabPane tab={`Comments (${comments?.length || 0})`} key="comments">
{/* Comments section */}
<div>
<S9Button
onClick={() => addComment({ text: 'New comment', isPrivate: false })}
>
Add Comment
</S9Button>
{/* Render comments */}
</div>
</TabPane>

<TabPane tab="Activity Log" key="logs">
{/* Activity timeline */}
<div>
{logs?.map(log => (
<div key={log.id}>
<span>{log.action}</span> -
<span>{new Date(log.createdAt).toLocaleString()}</span>
</div>
))}
</div>
</TabPane>

<TabPane tab={`Files (${attachments?.length || 0})`} key="files">
{/* File attachments */}
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadAttachment(file);
}}
/>
{/* Render attachments */}
</TabPane>
</S9Tabs>
</S9Col>
</S9Row>
</S9PageContent>
</S9Page>
);
};

Registering Components

Main Application Entry Point

Register your custom components in index.tsx:

// src/index.tsx
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AppProvider, AuthProvider } from '@april9au/stack9-react';
import {
UIProvider,
App,
// Import all Stack9 UI components you'll use
S9Button,
S9Table,
S9Form,
S9TextField,
S9NumericField,
S9DateField,
S9SingleDropDown,
S9MultiDropDown,
S9Tabs,
S9Row,
S9Col,
// ... other Stack9 components
} from '@april9au/stack9-ui';
import { ConfigProvider } from 'antd';

// Import your custom components
import { CustomerDetail } from './pages/CustomerDetail/CustomerDetail';
import { CustomerList } from './pages/CustomerList/CustomerList';
import { CustomerForm } from './pages/CustomerForm/CustomerForm';
import { CustomerProfileCard } from './components/CustomerProfileCard';
import { CustomerOrders } from './components/CustomerOrders';

// Configuration
import { axiosProvider, stack9Config } from './config';

// Register all components that can be used in dynamic screens
const components = [
// Stack9 components
S9Button,
S9Table,
S9Form,
S9TextField,
S9NumericField,
S9DateField,
S9SingleDropDown,
S9MultiDropDown,
S9Tabs,
S9Row,
S9Col,
// Your custom components
CustomerProfileCard,
CustomerOrders,
// Add all components that might be referenced in screen JSON
];

// Define custom routes
const customRoutes = [
{ route: '/customers/:id', component: <CustomerDetail /> },
{ route: '/customers', component: <CustomerList /> },
{ route: '/customers/new', component: <CustomerForm /> },
{ route: '/customers/:id/edit', component: <CustomerForm /> }
];

const root = createRoot(document.getElementById('root') as HTMLElement);

root.render(
<BrowserRouter>
<ConfigProvider>
<AppProvider axiosFactory={axiosProvider} config={stack9Config}>
<AuthProvider clientId={stack9Config.clientId}>
<UIProvider components={components}>
<App customRoutes={customRoutes} />
</UIProvider>
</AuthProvider>
</AppProvider>
</ConfigProvider>
</BrowserRouter>
);

Configuration Files

Create configuration for axios and Stack9:

// src/config/axios.ts
import axios, { AxiosInstance } from 'axios';
import { AppConfig } from '@april9au/stack9-react';

export const axiosProvider = (config: AppConfig): AxiosInstance => {
const instance = axios.create({
baseURL: config.apiUrl,
headers: {
'Content-Type': 'application/json'
}
});

// Add auth token to requests
instance.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

// Handle token refresh
instance.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
// Refresh token logic
const newToken = await refreshAuthToken();
if (newToken) {
error.config.headers.Authorization = `Bearer ${newToken}`;
return instance.request(error.config);
}
}
return Promise.reject(error);
}
);

return instance;
};
// src/config/stack9.ts
import { AppConfig } from '@april9au/stack9-react';

export const stack9Config: AppConfig = {
apiUrl: process.env.REACT_APP_API_URL || 'http://localhost:3000/api',
authUrl: process.env.REACT_APP_AUTH_URL || 'http://localhost:3000/auth',
appId: process.env.REACT_APP_ID || 'my-stack9-app',
clientId: process.env.REACT_APP_CLIENT_ID || '',
environment: (process.env.REACT_APP_ENV as any) || 'development',
features: {
darkMode: true,
betaFeatures: process.env.REACT_APP_ENV === 'development'
}
};

Working with Stack9 Hooks

Using Multiple Hooks Together

Create rich experiences by combining Stack9 hooks:

// src/pages/EnhancedCustomerView.tsx
import React from 'react';
import {
useEntitySchema,
useDropdownSearch,
useScreenQueryDefinition,
usePaginationByRoute,
useRouteAndQueryParams
} from '@april9au/stack9-react';

export const EnhancedCustomerView: React.FC = () => {
const { routeParams, queryParams } = useRouteAndQueryParams();
const customerId = routeParams.id;
const activeTab = queryParams.tab || 'details';

// Get entity schema for form generation
const { schema } = useEntitySchema('customer', customerId);

// Searchable dropdown for related entities
const {
options: companyOptions,
searchTerm,
setSearchTerm,
selectedValue: selectedCompany,
setSelectedValue: setSelectedCompany
} = useDropdownSearch(
'company',
'name',
'id',
'name'
);

// Execute custom query
const { data: relatedCustomers } = useScreenQueryDefinition(
'related-customers',
{ companyId: selectedCompany }
);

// Pagination for large lists
const {
currentPage,
pageSize,
setCurrentPage,
setPageSize
} = usePaginationByRoute(20);

return (
<div>
{/* Use the hooks data in your UI */}
<S9SingleDropDown
label="Company"
options={companyOptions}
value={selectedCompany}
onChange={setSelectedCompany}
onSearch={setSearchTerm}
showSearch
/>

{relatedCustomers && (
<S9Table
dataSource={relatedCustomers}
pagination={{
current: currentPage,
pageSize: pageSize,
onChange: setCurrentPage,
onShowSizeChange: (_, size) => setPageSize(size)
}}
/>
)}
</div>
);
};

Custom Hook Composition

Build custom hooks that combine Stack9 functionality:

// src/hooks/useCustomerDashboard.ts
import { useState, useEffect } from 'react';
import {
useEntitySchema,
useEntityTasks,
useEntityLogs,
useStack9
} from '@april9au/stack9-react';

export const useCustomerDashboard = (customerId: number) => {
const [stats, setStats] = useState<any>(null);
const [recentOrders, setRecentOrders] = useState([]);
const { entityService, queryService } = useStack9();

const { schema } = useEntitySchema('customer', customerId);
const { tasks } = useEntityTasks('customer', customerId);
const { logs } = useEntityLogs('customer', customerId);

useEffect(() => {
fetchDashboardData();
}, [customerId]);

const fetchDashboardData = async () => {
// Fetch customer stats
const statsResult = await queryService.execute('customer-stats', {
customerId
});
setStats(statsResult.data[0]);

// Fetch recent orders
const orders = await entityService.list('order', {
filter: { customer_id: customerId },
sort: 'created_at DESC',
limit: 5
});
setRecentOrders(orders.items);
};

const openTasks = tasks?.filter(t => t.status !== 'completed') || [];
const recentActivity = logs?.slice(0, 10) || [];

return {
schema,
stats,
recentOrders,
openTasks,
recentActivity,
refresh: fetchDashboardData
};
};

Forms and Data Entry

Building Dynamic Forms

Create forms that adapt based on entity schema:

// src/components/DynamicEntityForm.tsx
import React, { useEffect } from 'react';
import { S9Form, S9TextField, S9NumericField, S9DateField, S9SingleDropDown, S9Button } from '@april9au/stack9-ui';
import { useEntitySchema, useStack9 } from '@april9au/stack9-react';
import { Form, message } from 'antd';

interface DynamicEntityFormProps {
entityKey: string;
entityId?: number;
onSuccess?: (data: any) => void;
}

export const DynamicEntityForm: React.FC<DynamicEntityFormProps> = ({
entityKey,
entityId,
onSuccess
}) => {
const [form] = Form.useForm();
const { schema, isLoading } = useEntitySchema(entityKey, entityId);
const { entityService } = useStack9();

useEffect(() => {
if (entityId && schema) {
loadEntity();
}
}, [entityId, schema]);

const loadEntity = async () => {
try {
const entity = await entityService.findById(entityKey, entityId!);
form.setFieldsValue(entity);
} catch (error) {
message.error('Failed to load entity');
}
};

const handleSubmit = async (values: any) => {
try {
let result;
if (entityId) {
result = await entityService.update(entityKey, entityId, values);
message.success('Updated successfully');
} else {
result = await entityService.create(entityKey, values);
message.success('Created successfully');
}
onSuccess?.(result);
} catch (error) {
message.error('Operation failed');
}
};

const renderField = (fieldName: string, fieldDef: any) => {
const commonProps = {
name: fieldName,
label: fieldDef.label || fieldName,
rules: [
{ required: fieldDef.required, message: `${fieldDef.label} is required` }
]
};

switch (fieldDef.type) {
case 'string':
case 'text':
return <S9TextField {...commonProps} />;

case 'integer':
case 'decimal':
return (
<S9NumericField
{...commonProps}
min={fieldDef.min}
max={fieldDef.max}
precision={fieldDef.precision}
/>
);

case 'date':
case 'datetime':
return (
<S9DateField
{...commonProps}
showTime={fieldDef.type === 'datetime'}
/>
);

case 'enum':
return (
<S9SingleDropDown
{...commonProps}
options={fieldDef.enum.map((val: string) => ({
label: val,
value: val
}))}
/>
);

default:
return <S9TextField {...commonProps} />;
}
};

if (isLoading || !schema) {
return <div>Loading...</div>;
}

return (
<S9Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
{Object.entries(schema.fields).map(([fieldName, fieldDef]) => (
<div key={fieldName}>
{renderField(fieldName, fieldDef)}
</div>
))}

<S9Button type="primary" htmlType="submit">
{entityId ? 'Update' : 'Create'}
</S9Button>
</S9Form>
);
};

Complex Form with Validation

Build forms with advanced validation and field dependencies:

// src/pages/OrderForm/OrderForm.tsx
import React, { useState, useEffect } from 'react';
import {
S9Form,
S9TextField,
S9NumericField,
S9DateField,
S9SingleDropDown,
S9Table,
S9Button,
S9Row,
S9Col
} from '@april9au/stack9-ui';
import { useDropdownSearch, useStack9 } from '@april9au/stack9-react';
import { Form, message } from 'antd';

export const OrderForm: React.FC = () => {
const [form] = Form.useForm();
const [orderItems, setOrderItems] = useState<any[]>([]);
const [totalAmount, setTotalAmount] = useState(0);
const { entityService } = useStack9();

// Customer search dropdown
const {
options: customerOptions,
setSearchTerm: setCustomerSearch
} = useDropdownSearch('customer', 'name', 'id', 'name');

// Product search dropdown
const {
options: productOptions,
setSearchTerm: setProductSearch
} = useDropdownSearch('product', 'name', 'id', 'name');

// Calculate total when items change
useEffect(() => {
const total = orderItems.reduce((sum, item) => {
return sum + (item.quantity * item.unitPrice);
}, 0);
setTotalAmount(total);
form.setFieldsValue({ totalAmount: total });
}, [orderItems]);

const handleAddItem = () => {
const values = form.getFieldValue('newItem');
if (values?.productId && values?.quantity) {
const product = productOptions.find(p => p.value === values.productId);
setOrderItems([
...orderItems,
{
...values,
productName: product?.label,
total: values.quantity * values.unitPrice
}
]);
form.setFieldsValue({ newItem: {} });
}
};

const handleRemoveItem = (index: number) => {
setOrderItems(orderItems.filter((_, i) => i !== index));
};

const handleSubmit = async (values: any) => {
try {
const orderData = {
...values,
items: orderItems,
status: 'pending'
};

const result = await entityService.create('order', orderData);
message.success(`Order #${result.orderNumber} created successfully`);

// Reset form
form.resetFields();
setOrderItems([]);
} catch (error) {
message.error('Failed to create order');
}
};

const columns = [
{
title: 'Product',
dataIndex: 'productName',
key: 'productName'
},
{
title: 'Quantity',
dataIndex: 'quantity',
key: 'quantity'
},
{
title: 'Unit Price',
dataIndex: 'unitPrice',
key: 'unitPrice',
render: (value: number) => `$${value.toFixed(2)}`
},
{
title: 'Total',
dataIndex: 'total',
key: 'total',
render: (value: number) => `$${value.toFixed(2)}`
},
{
title: 'Action',
key: 'action',
render: (_: any, __: any, index: number) => (
<S9Button danger onClick={() => handleRemoveItem(index)}>
Remove
</S9Button>
)
}
];

return (
<S9Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<S9Row gutter={16}>
<S9Col span={12}>
<S9SingleDropDown
name="customerId"
label="Customer"
options={customerOptions}
showSearch
onSearch={setCustomerSearch}
rules={[{ required: true, message: 'Please select a customer' }]}
/>
</S9Col>
<S9Col span={12}>
<S9DateField
name="orderDate"
label="Order Date"
rules={[{ required: true }]}
/>
</S9Col>
</S9Row>

<div style={{ marginTop: 24, marginBottom: 24 }}>
<h3>Order Items</h3>
<S9Row gutter={16}>
<S9Col span={8}>
<S9SingleDropDown
name={['newItem', 'productId']}
label="Product"
options={productOptions}
showSearch
onSearch={setProductSearch}
/>
</S9Col>
<S9Col span={4}>
<S9NumericField
name={['newItem', 'quantity']}
label="Quantity"
min={1}
/>
</S9Col>
<S9Col span={4}>
<S9NumericField
name={['newItem', 'unitPrice']}
label="Unit Price"
min={0}
precision={2}
prefix="$"
/>
</S9Col>
<S9Col span={8}>
<S9Button
type="dashed"
onClick={handleAddItem}
style={{ marginTop: 29 }}
>
Add Item
</S9Button>
</S9Col>
</S9Row>

<S9Table
columns={columns}
dataSource={orderItems}
pagination={false}
style={{ marginTop: 16 }}
/>
</div>

<S9Row gutter={16}>
<S9Col span={12}>
<S9TextField
name="notes"
label="Order Notes"
multiline
rows={4}
/>
</S9Col>
<S9Col span={12}>
<S9NumericField
name="totalAmount"
label="Total Amount"
disabled
prefix="$"
precision={2}
value={totalAmount}
/>
</S9Col>
</S9Row>

<div style={{ marginTop: 24 }}>
<S9Button type="primary" htmlType="submit" size="large">
Create Order
</S9Button>
</div>
</S9Form>
);
};

Advanced Patterns

Custom Screen with JSON Configuration

Reference custom components in screen JSON:

// src/screens/customer-detail.json
{
"head": {
"title": "Customer Detail",
"icon": "UserOutlined"
},
"screenType": "detailView",
"detailQuery": "customer-by-id",
"components": {
"type": "container",
"children": [
{
"type": "row",
"gutter": [24, 24],
"children": [
{
"type": "col",
"span": 8,
"children": [
{
"type": "CustomerProfileCard",
"props": {
"dataSource": "{{entity}}"
}
}
]
},
{
"type": "col",
"span": 16,
"children": [
{
"type": "S9Tabs",
"defaultActiveKey": "details",
"children": [
{
"type": "TabPane",
"tab": "Details",
"key": "details",
"children": [
{
"type": "S9Fieldset",
"schema": "{{schema}}",
"mode": "view"
}
]
},
{
"type": "TabPane",
"tab": "Orders",
"key": "orders",
"children": [
{
"type": "CustomerOrders",
"props": {
"customerId": "{{entity.id}}"
}
}
]
}
]
}
]
}
]
}
]
}
}

State Management with Context

Create a context provider for complex state:

// src/providers/OrderProvider.tsx
import React, { createContext, useContext, useState, useCallback } from 'react';

interface OrderContextType {
cart: CartItem[];
addToCart: (item: CartItem) => void;
removeFromCart: (itemId: number) => void;
clearCart: () => void;
totalAmount: number;
}

const OrderContext = createContext<OrderContextType | undefined>(undefined);

export const OrderProvider: React.FC<{ children: React.ReactNode }> = ({
children
}) => {
const [cart, setCart] = useState<CartItem[]>([]);

const addToCart = useCallback((item: CartItem) => {
setCart(prev => {
const existing = prev.find(i => i.productId === item.productId);
if (existing) {
return prev.map(i =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
);
}
return [...prev, item];
});
}, []);

const removeFromCart = useCallback((itemId: number) => {
setCart(prev => prev.filter(i => i.productId !== itemId));
}, []);

const clearCart = useCallback(() => {
setCart([]);
}, []);

const totalAmount = cart.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);

return (
<OrderContext.Provider
value={{ cart, addToCart, removeFromCart, clearCart, totalAmount }}
>
{children}
</OrderContext.Provider>
);
};

export const useOrder = () => {
const context = useContext(OrderContext);
if (!context) {
throw new Error('useOrder must be used within OrderProvider');
}
return context;
};

Performance Optimization

Optimize large lists and complex components:

// src/components/OptimizedCustomerList.tsx
import React, { useMemo, useCallback, memo } from 'react';
import { S9Table } from '@april9au/stack9-ui';
import { FixedSizeList } from 'react-window';

const CustomerRow = memo(({ customer, onEdit, onDelete }: any) => {
return (
<div style={{ display: 'flex', padding: '8px 16px' }}>
<span style={{ flex: 1 }}>{customer.name}</span>
<span style={{ flex: 1 }}>{customer.email}</span>
<button onClick={() => onEdit(customer.id)}>Edit</button>
<button onClick={() => onDelete(customer.id)}>Delete</button>
</div>
);
});

export const OptimizedCustomerList: React.FC<{ customers: any[] }> = ({
customers
}) => {
const handleEdit = useCallback((id: number) => {
console.log('Edit customer:', id);
}, []);

const handleDelete = useCallback((id: number) => {
console.log('Delete customer:', id);
}, []);

const Row = useCallback(
({ index, style }: any) => (
<div style={style}>
<CustomerRow
customer={customers[index]}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
),
[customers, handleEdit, handleDelete]
);

// For very large lists, use react-window
if (customers.length > 1000) {
return (
<FixedSizeList
height={600}
itemCount={customers.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}

// For moderate lists, use standard table with pagination
const columns = useMemo(
() => [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
sorter: true
},
{
title: 'Email',
dataIndex: 'email',
key: 'email'
},
{
title: 'Actions',
key: 'actions',
render: (_, record: any) => (
<>
<button onClick={() => handleEdit(record.id)}>Edit</button>
<button onClick={() => handleDelete(record.id)}>Delete</button>
</>
)
}
],
[handleEdit, handleDelete]
);

return (
<S9Table
columns={columns}
dataSource={customers}
rowKey="id"
pagination={{ pageSize: 50 }}
/>
);
};

Best Practices

1. Component Organization

Structure your components for maintainability:

src/
├── components/ # Reusable UI components
│ ├── common/ # Generic components
│ ├── domain/ # Business-specific components
│ └── layout/ # Layout components
├── pages/ # Page-level components
│ └── CustomerDetail/
│ ├── CustomerDetail.tsx
│ ├── CustomerDetail.styles.ts
│ ├── CustomerDetail.test.tsx
│ └── index.ts
├── hooks/ # Custom hooks
├── services/ # API services
├── providers/ # Context providers
└── utils/ # Utility functions

2. Type Safety

Always define TypeScript interfaces:

// src/types/customer.ts
export interface Customer {
id: number;
name: string;
email: string;
phone?: string;
company?: string;
status: CustomerStatus;
creditLimit: number;
createdAt: string;
updatedAt: string;
}

export enum CustomerStatus {
Active = 'active',
Inactive = 'inactive',
Pending = 'pending'
}

export interface CustomerFormData
extends Omit<Customer, 'id' | 'createdAt' | 'updatedAt'> {}

3. Error Handling

Implement comprehensive error boundaries:

// src/components/ErrorBoundary.tsx
import React from 'react';
import { S9Result, S9Button } from '@april9au/stack9-ui';

class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error?: Error }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: any) {
console.error('Error caught by boundary:', error, errorInfo);
// Send to error tracking service
}

render() {
if (this.state.hasError) {
return (
<S9Result
status="500"
title="Something went wrong"
subTitle={this.state.error?.message}
extra={
<S9Button onClick={() => window.location.reload()}>
Refresh Page
</S9Button>
}
/>
);
}

return this.props.children;
}
}

4. Loading States

Always provide loading feedback:

// src/components/LoadingWrapper.tsx
import React from 'react';
import { Spin, Skeleton } from 'antd';

interface LoadingWrapperProps {
loading: boolean;
error?: Error;
skeleton?: boolean;
children: React.ReactNode;
}

export const LoadingWrapper: React.FC<LoadingWrapperProps> = ({
loading,
error,
skeleton,
children
}) => {
if (error) {
return <div>Error: {error.message}</div>;
}

if (loading) {
return skeleton ? (
<Skeleton active paragraph={{ rows: 4 }} />
) : (
<Spin size="large" />
);
}

return <>{children}</>;
};

5. Accessibility

Ensure components are accessible:

// src/components/AccessibleForm.tsx
export const AccessibleForm: React.FC = () => {
return (
<form aria-label="Customer form">
<label htmlFor="name">
Name
<span aria-label="required">*</span>
</label>
<input
id="name"
type="text"
aria-required="true"
aria-describedby="name-error"
/>
<span id="name-error" role="alert" aria-live="polite">
{/* Error message */}
</span>
</form>
);
};

Troubleshooting

Common Issues and Solutions

Issue: Component not found in screen

  • Solution: Ensure component is registered in UIProvider

Issue: Hooks returning undefined

  • Solution: Check that providers wrap your component

Issue: Form values not updating

  • Solution: Ensure form fields have unique name props

Issue: API calls failing

  • Solution: Check axios interceptors and auth tokens

Issue: Performance issues with large lists

  • Solution: Implement pagination or virtual scrolling

Next Steps

Now that you understand custom UI development in Stack9:

  1. Explore the examples - Review the instance projects for real-world patterns
  2. Read the API docs - Deep dive into Stack9 React, Stack9 UI, and Stack9 SDK
  3. Build your first screen - Start with a simple list view and progressively add features
  4. Join the community - Share your experiences and get help in the Stack9 Discord

Summary

Custom UI development in Stack9 provides the flexibility to create tailored user experiences while leveraging the platform's powerful data management and component library. By following the patterns and practices in this guide, you can build sophisticated applications that are maintainable, performant, and user-friendly.

Key takeaways:

  • Use Stack9 hooks for data fetching and state management
  • Leverage Stack9 UI components for consistent design
  • Register custom components for use in dynamic screens
  • Follow TypeScript and React best practices
  • Optimize for performance with proper pagination and memoization

Happy coding!