Skip to main content

Develop Todo App

Create farrow.config.js#

First, in the farrow.config.js configuration file, configure the rules for generating farrow-api.

const { createFarrowConfig } = require('farrow');
module.exports = createFarrowConfig({
server: {
src: './server',
dist: './dist/server',
},
api: [
{
src: 'http://localhost:3003/api/todo',
dist: `${__dirname}/src/api/todo.ts`,
},
],
});

Generate the interface http://localhost:3003/api/todo to the local ${__dirname}/src/api/todo.ts via codegen.

After running farrow dev, new files will be added in the directory specified by the dist configuration.

/**
* This file was generated by farrow-api
* Don't modify it manually
*/
import { createApiPipelineWithUrl, ApiInvokeOptions } from 'farrow-api-client';
/**
* {@label Todo}
*/
export type Todo = {
/**
* @remarks todo id
*/
id: number;
/**
* @remarks todo content
*/
content: string;
/**
* @remarks todo status
*/
completed: boolean;
};
/**
* {@label AddTodoInput}
*/
export type AddTodoInput = {
/**
* @remarks todo content to add
*/
content: string;
};
/**
* {@label InvalidAddTodoInput}
*/
export type InvalidAddTodoInput = {
type: 'InvalidAddTodoInput';
message: string;
};
/**
* {@label AddTodoSuccess}
*/
export type AddTodoSuccess = {
type: 'AddTodoSuccess';
/**
* @remarks a new todo
*/
todo: Todo;
};
/**
* {@label AddTodoOutput}
*/
export type AddTodoOutput = InvalidAddTodoInput | AddTodoSuccess;
/**
* {@label RemoveTodoInput}
*/
export type RemoveTodoInput = {
/**
* @remarks todo id wait for removing
*/
todoId: number;
};
/**
* {@label TodoIdNotFound}
*/
export type TodoIdNotFound = {
type: 'TodoIdNotFound';
/**
* @remarks invalid todo id
*/
todoId: number;
};
/**
* {@label RemoveTodoSuccess}
*/
export type RemoveTodoSuccess = {
type: 'RemoveTodoSuccess';
/**
* @remarks todo id that removed
*/
todoId: number;
/**
* @remarks current todos
*/
todos: Todo[];
};
/**
* {@label UpdateTodoInput}
*/
export type UpdateTodoInput = {
/**
* @remarks todo id wait for update
*/
todoId: number;
/**
* @remarks new todo content
*/
content?: string | null | undefined;
/**
* @remarks new todo status
*/
completed?: boolean | null | undefined;
};
/**
* {@label UpdateTodoSuccess}
*/
export type UpdateTodoSuccess = {
type: 'UpdateTodoSuccess';
/**
* @remarks todo id that updated
*/
todoId: number;
/**
* @remarks current todos
*/
todos: Todo[];
};
/**
* {@label UpdateTodoOutput}
*/
export type UpdateTodoOutput = TodoIdNotFound | UpdateTodoSuccess;
export const url = 'http://localhost:3003/api/todo';
export const apiPipeline = createApiPipelineWithUrl(url);
export const api = {
/**
* @remarks get todos
*/
getTodos: (input: {}, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['getTodos'], input },
options
) as Promise<{
/**
* @remarks all todos
*/
todos: Todo[];
}>,
/**
* @remarks add todo
*/
addTodo: (input: AddTodoInput, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['addTodo'], input },
options
) as Promise<AddTodoOutput>,
/**
* @remarks remove todo
*/
removeTodo: (input: RemoveTodoInput, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['removeTodo'], input },
options
) as Promise<TodoIdNotFound | RemoveTodoSuccess>,
/**
* @remarks update todo
*/
updateTodo: (input: UpdateTodoInput, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['updateTodo'], input },
options
) as Promise<UpdateTodoOutput>,
/**
* @remarks clear completed
*/
clearCompleted: (input: {}, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['clearCompleted'], input },
options
) as Promise<{
/**
* @remarks current todos
*/
todos: Todo[];
}>,
};

As above, we can see that the type definition of the interface, as well as the call function for the api client object, is generated.

Import the generated modules#

Then, in the src/App.tsx component, we can directly import the types and objects contained in the generated module.

import React, {
useState,
useEffect,
ChangeEventHandler,
MouseEventHandler,
KeyboardEventHandler,
} from 'react';
import { api as TodoApi, Todo } from './api/todo';

Implement TodoItem component#

Then we add the component implementation, defining the TodoItem component first, which uses the generated Todo type directly.

type TodoItemProps = {
todo: Todo;
onRemove: (todoId: Todo['id']) => unknown;
onUpdate: (
todoId: Todo['id'],
todoData: Partial<Omit<Todo, 'id'>>
) => unknown;
onToggle: (todo: Todo) => unknown;
};
function TodoItem(props: TodoItemProps) {
const [text, setText] = useState(props.todo.content);
const [editable, setEditable] = useState(false);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setText(event.target.value);
};
const handleDblClick: MouseEventHandler<HTMLLabelElement> = () => {
setEditable(true);
};
const handleUpdate = () => {
props.onUpdate(props.todo.id, {
content: text,
});
setEditable(false);
};
const handleKeyUp: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter') {
handleUpdate();
} else if (event.key === 'Escape') {
setText(props.todo.content);
setEditable(false);
}
};
const handleBlur = () => {
if (text !== props.todo.content) {
handleUpdate();
}
};
return (
<div>
<button onClick={() => props.onToggle(props.todo)}>
{props.todo.completed ? 'completed' : 'active'}
</button>
<button onClick={() => props.onRemove(props.todo.id)}>remove</button>
{!editable && <label onClick={handleDblClick}>{text}</label>}
{editable && (
<input
type="text"
value={text}
onChange={handleChange}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
/>
)}
</div>
);
}

Implement App component#

Then define the App component.

function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState('');
const handleAdd = async () => {
const result = await TodoApi.addTodo({
content: text,
});
if (result.type === 'InvalidAddTodoInput') {
alert(result.message);
} else if (result.type === 'AddTodoSuccess') {
setTodos(todos.concat(result.todo));
setText('');
}
};
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setText(event.target.value);
};
const handleKeyUp: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter') {
handleAdd();
}
};
const handleRemove: TodoItemProps['onRemove'] = async (todoId) => {
let result = await TodoApi.removeTodo({
todoId: todoId,
});
if (result.type === 'TodoIdNotFound') {
alert(`todoId ${todoId} not found`);
} else {
setTodos(result.todos);
}
};
const handleUpdate: TodoItemProps['onUpdate'] = async (todoId, todoData) => {
let result = await TodoApi.updateTodo({
todoId: todoId,
content: todoData.content,
completed: todoData.completed,
});
if (result.type === 'TodoIdNotFound') {
alert(`todoId ${todoId} not found`);
} else {
setTodos(result.todos);
}
};
const handleToggle: TodoItemProps['onToggle'] = async (todo) => {
let result = await TodoApi.updateTodo({
todoId: todo.id,
completed: !todo.completed,
});
if (result.type === 'TodoIdNotFound') {
alert(`todoId ${todo.id} not found`);
} else {
setTodos(result.todos);
}
};
const handleClearCompleted = async () => {
let result = await TodoApi.clearCompleted({});
setTodos(result.todos);
};
useEffect(() => {
const task = async () => {
const result = await TodoApi.getTodos({});
setTodos(result.todos);
};
task().catch((error) => {
console.log('error', error);
});
}, []);
return (
<div>
<header>
<input
type="text"
placeholder="input your todo content"
onChange={handleChange}
value={text}
onKeyUp={handleKeyUp}
/>
<button onClick={handleClearCompleted}>clear completed</button>
</header>
<hr />
<main>
{todos.map((todo) => {
return (
<TodoItem
key={todo.id}
todo={todo}
onRemove={handleRemove}
onToggle={handleToggle}
onUpdate={handleUpdate}
/>
);
})}
</main>
</div>
);
}
export default App;

In the handleAddTodo function, we call the api.addTodo interface and process the Tagged Unions type it returns.

const handleAdd = async () => {
const result = await TodoApi.addTodo({
content: text,
});
if (result.type === 'InvalidAddTodoInput') {
// ๅค„็†้žๆณ•่พ“ๅ…ฅ
alert(result.message);
} else if (result.type === 'AddTodoSuccess') {
// ๅค„็†ๆˆๅŠŸๆƒ…ๅ†ต
setTodos(todos.concat(result.todo));
setText('');
}
};

The pattern is the same in other interfaces such as handleRemove.

In summary, the best practice for farrow-api is to

  • On the server side, use Tagged Unions to encode the input/output of the interface
  • On the client side, use result.type to distinguish between different cases of data consumption.

The finished code can be found in the repository farrow-js/farrow-vite-todo-app.