Hyperapp
を学ぶ(フロントエンドに慣れる)ために定番のTODOアプリを作りました。
react
やangular
のチュートリアルに触れたことがある程度の経験の僕でも結構あっさり作れて楽しいです。
開発環境はこの記事と同じです。
state
./src/state/todo.ts
export interface ITodoItem {
id: string;
done: boolean;
text: string;
}
export interface ITodoState {
inputText: string;
todos: ITodoItem[];
}
export const todoState: ITodoState = {
inputText: "",
todos: [
/* ダミーのTodoを格納しておきます */
{ id: "1", done: false, text: "dummy todo 1" },
{ id: "2", done: false, text: "dummy todo 2" },
{ id: "3", done: false, text: "dummy todo 3" }
]
};
-
ITodoItem
:個々のTODO-
id
:個々のTODOを区別する一意なID -
done
:作業済みかどうか -
text
:内容です
-
-
inputText
:現在フォームに入力中の値です。 -
todos
:TODO一覧です。簡略化のためDBやローカルストレージは使わずメモリ上に保持します。
actions
./src/actions/todo.ts
import { ActionsType } from "hyperapp";
import { ITodoState } from "../states/todo";
const uniqueId: () => string = () =>
Math.random()
.toString(34)
.slice(2);
export interface ITodoActions {
onInput: (e: Event) => ITodoState;
add: () => ITodoState;
toggle: (id: string) => ITodoState;
delete: () => ITodoState;
}
export const todoActions: ActionsType<ITodoState, ITodoActions> = {
onInput: (e: Event) => (state): ITodoState => {
const target: EventTarget | null = e.target;
if (target && target instanceof HTMLInputElement) {
return {
inputText: target.value,
todos: state.todos
};
} else {
return state;
}
},
add: () => (state): ITodoState =>
state.inputText
? {
inputText: "",
todos: [
...state.todos,
{
id: uniqueId(),
done: false,
text: state.inputText
}
]
}
: state,
toggle: (id: string) => (state): ITodoState => ({
inputText: state.inputText,
todos: state.todos.map(t => ({
id: t.id,
done: t.id === id ? !t.done : t.done,
text: t.text
}))
}),
delete: () => (state): ITodoState => ({
inputText: state.inputText,
todos: state.todos.filter(t => !t.done)
})
};
TypeScriptで戻り値の型を明示したおかげで個々のactionが複雑になってもvscodeに助けられながらスムーズに書くことができました。
-
uniqueId
:(ほぼ)ユニークなIDを生成します。- 実行すると
03ndvcgpbxi3
みたいのが返ります。 - TODOごとのIDに使います。
- 実行すると
-
onInput
:入力があるたびにstateのinputText
を更新します。- 入力値の値を
e.target.value
と一気に得ようとしたのですが、TypeScriptはnull安全な言語なのでtarget
がnull
でないかつHTMLInputElement
であることを確認するコードが必要でした。 - コードがちょっと増えるけど、ミスなく書けるのはやっぱり素敵です。
- 入力値の値を
-
add
:inputText
の値をクリアしTodos
に加えます。- 空のTodoが加わらないように未入力の場合、そのまま
state
を返します。
- 空のTodoが加わらないように未入力の場合、そのまま
-
toggle
:対象のTODOの未着手/作業済を切り替えます。 -
delete
:作業済みのTODOを一覧から削除します。
view
./src/views/todo.ts
import { h, View, Component } from "hyperapp";
import { ITodoState, ITodoItem } from "../states/todo";
import { ITodoActions, todoActions } from "../actions/todo";
const TodoItem: Component<ITodoItem> = ({ id, done, text }) =>
done ? <strike>{text}</strike> : <span>{text}</span>;
export const TodoList: View<ITodoState, ITodoActions> = (state, actions) => (
<main>
<h1>To-Do List</h1>
<ul>
{state.todos.map(t => (
<li key={t.id} onclick={() => actions.toggle(t.id)}>
<TodoItem id={t.id} done={t.done} text={t.text} />
</li>
))}
</ul>
<input
type="text"
placeholder="Add a to-do ..."
oninput={(e: Event) => actions.onInput(e)}
value={state.inputText}
/>
<button onclick={() => actions.add()}>ADD</button>
<button onclick={() => actions.delete()}>DELETE</button>
</main>
);
- コンポーネントへの引数の渡し方に少し詰まりました。
- コンポーネント側で引数を
({ id, done, text })
とすると`利用する際にJSX Attributeで渡すことができました。 - また、その際不正な引数名や型を与えるとエラーを出してくれることに感動しました。
- コンポーネント側で引数を
Index.ts
とIndex.html
./src/index.ts
import { app } from "hyperapp";
import { ITodoState, todoState } from "./states/todo";
import { ITodoActions, todoActions } from "./actions/todo";
import { TodoList } from "./views/todo";
import { withLogger } from "@hyperapp/logger";
withLogger(app)(todoState, todoActions, TodoList, document.body);
-
@hyperapp/logger
:コンソールにstateやactionが吐き出します。- これでデバッグできる!
- 型定義ファイルが見つかりませんでしたが、
withLogger(app)(...)
しただけなので特に困りませんでした(が、vscodeの「型ないよ」警告が気になる...)
./index.html
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>To-Do List</title>
</head>
<body>
<script src='./src/index.ts'></script>
</body>
</html>
今度はメモリ上のデータでなく、サーバー側のAPIと連携して実際にDBを使うアプリにチャレンジしてみたいです!
あと、actionがかなり肥大化してるのでそこも適切にリファクタリングしたい...