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
- Setting Up Your Development Environment
- Creating Custom Components
- Building a Custom Detail View
- Registering Components
- Working with Stack9 Hooks
- Forms and Data Entry
- Advanced Patterns
- Best Practices
Overview
Stack9 provides three ways to build user interfaces:
- Configuration-driven screens - Define UI through JSON configuration
- Custom React components - Build completely custom UI with React
- 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
nameprops
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:
- Explore the examples - Review the instance projects for real-world patterns
- Read the API docs - Deep dive into Stack9 React, Stack9 UI, and Stack9 SDK
- Build your first screen - Start with a simple list view and progressively add features
- 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!