はじめに
もうすぐ2025年も終わりますが、今年はエンジニアにとって衝撃的な1年になったなーと思います。
特にAI技術。アイデア出しから設計、実装まで、AIを使うのが当たり前の存在になりました。
特に AIコーディング 周りの進化はすごくて、アイデア出しから設計、実装まで、AIを使うのがほぼ当たり前になりました。
去年までの自分にとって「AIを使った開発」といえば、コードのサジェスト(補完)や壁打ちが中心でした。
ところが今では、VSCode上で Copilot / Cline / Codex などを使いながら、「この機能が欲しいから実装して!」くらいの温度感でも、ある程度“形”のあるものが返ってくるようになっています。
今ではあまりにも自然に使うようになったので忘れがちですが、去年の自分からするとかなり劇的な変化です。
ーーー
一方で、「まだAIは実務で使えない」「思ったようなコードが返ってこない」という声もちらほら耳にします。
実際、自分自身も未だ「こんな簡単な実装もできないの?」となることがあります。
ただ実務の中で何度もtry-errorを繰り返す中で、うまくいかない問題は自分の使い方や指示の出し方にあるのではないか、と思うようになりました。
本記事では自分がここ1~2年でAIを使ってきた経験を元に、どのようなツールを使い、何を意識してAIに「実務で使えるコード」を作ってもらえるようにしているのか、を整理していきます。
ここ数年の変遷
ここ1~2年、実際に自分がどのようにAIを取り入れてきたのか。試したツールを時系列順で振り返ってみます。
先に断っておくと、ここで紹介するのは「このツールが一番すごい」という話ではありません。ほとんどが僕の感覚で選定して使っていたものになるので、そこはご認識ください..!
今まで自分が使ってきた主なツールは以下です(chatGPT君に整理してもらいました)。
| 時期 / ツール | AIの主な役割 | 人がやっていたこと | 1タスクの体感コスト | 実務投入度 |
|---|---|---|---|---|
| Copilot時代 | コード補完・サジェスト | 設計・実装・修正ほぼ全て | 高い | ◎(補助的) |
| Cursor | 一部自動生成 | 設計・微調整・書き直し | やや高い | △ |
| VSCode Agent | 小さな変更の自動化 | 設計・判断・修正 | 中 | ○ |
| Cline(Roo Code) | 小〜中規模の実装 | 設計・レビュー | 低い | ◎ |
| Devin | PR作成・簡単な修正 | 設計・最終調整 | 中 | ○ |
| Codex | コード理解+実装 | 設計・判断 | 低い(時間はかかる) | ◎(使い分け) |
はじめはCopilotをVSCodeを動かし始めて、コードのサジェストを行ってくれるようになりました。それまではリポジトリ内の似たようなコードをコピペしてそれをベースに実装したりしていたのが、
// Cognitoを用いてログインする関数
const login = ...
くらいまで書くとサジェストが出てくるので、あとはtabキーを押すとそれなりのコードが出てくるようになりました。最初これが出てきた時はコーディング楽になったなーと思いましたね。
その後CursorやVSCode Agent(実際はCopilotAgentだったかも)が出てきて、次はサジェストではなくchat形式で「/loginページを追加してCognitoを使ってログインする機能を実装して」みたいに指示を出すと、ディレクトリ・ファイルを横断的に実装してくれるようになりました。
この辺りから「あれ、エンジニアいらなくなるのでは..?」と危機感を覚え始めましたね。SNSとかでも同じことを思っている人がちらほらいて、やっぱみんな感じているよなーと。。
そこからCline(Roo Code)・Devin・Codexなどが出てきて、issue~PR作成を任せたいならDevin、開発アシストならClineやCodexといった感じで使い分けが始まりました。
ーーー
ここまでを振り返ると、一見AIが進化して開発がすごく楽になった、
「もうエンジニアいらないじゃん!(悲しみ)」と思うかもしれません。
ですが、人生そんなにうまくいきません。
実際にAIに指示を出してみると、
- 期待通り、あるいはそれ以上の実装をしてくれることもあれば
- 「え、なんでそうなる?」と思うような実装になることもあります
要は、場面によって結果にかなりムラがあるんです。
この違いはいったい何なのだろうかと。。
モデルとツールを使い分けてみた
まずは最初に疑ったのは「モデル選定」でした。
例えばClineを使う場合、Claude、GPT、Geminiなど様々なモデルが選択できるので、
うまくいかない時は「別のモデル使ってみるかー?」といくつか試してみたこともあります。
実際にモデルを切り替えて試すと、ある程度の精度の違いやコードの書き方に違いは感じました。
ただ、「このモデルなら上手く実装してくれるぞ!」と思えるほどの差はなく、アウトプットの“形”が少し変わる程度でした。
次に試したのが、「ツールの使い分け」です。
Cline(Roo Code含む)、Devin、Codex などを実際に使ってみて、それぞれの得意・不得意が少しずつ見えてきました。
-
Cline(Roo Code)
- 既存ファイルへの小〜中規模な関数追加であれば、速度・精度も高く、開発アシストとして使いやすい
-
Devin
- GitHubと連携すると、Issue作成からPR作成まで一通り任せられるのは便利。
- ただし、コードの精度はそこまで高くなく、最終的な調整は必要
- DeepWiki機能はかなり優秀
-
Codex
- 指示が多少曖昧でも、リポジトリ内のコードや依存関係からコンテキストを読み取り、全体に沿った実装をしてくれる。
- Clineに比べるとタスク完了までに時間はかかる
これにより、
- 小規模な実装は Cline
- ある程度大きな変更や文脈理解が必要な実装は Codex
- サービス(リポジトリ)の今の仕様を調べるならDevinのDeepWiki機能
といった形で、今ではツールを使い分けるようになりました。
モデル選定よりも、「どのツールで任せるか」を意識することで、体感としてはかなりマシになりました。
ただ、それでも「んー、違うんだよな。そういう実装をしてほしいんじゃない」と思う場面はなくなりませんでした。さらに「いい感じに実装してもらいたい」場合はどうすればいいのか。。
AIへの指示(お願い)を意識する
「プロンプトエンジニアリング」という言葉があります。
上の記事に
プロンプトエンジニアリングにより、AI アプリケーションの効率と効果が高まります。アプリケーション開発者は通常、自由形式のユーザー入力をプロンプト内にカプセル化してから AI モデルに渡します。
と書いてある通り、AIへの指示(プロンプト)の出し方によってアウトプットの質が変わる、という考え方です。
ーーー
例えば、「(対象のコードを選択して)コードが少し冗長なのでもう少し理解しやすいようにリファクタリングして。」というリファクタリングの指示を出したとします。
この時、ある程度周辺のコードやAIの持つ前提知識をもとに頑張ってリファクタリングしてくれますが、結果として「うーん、なんか違うんだよな。」みたいなことは起きやすいんですよね。
例えば以下のようなコードについて、リファクタリングを行うとします。
BigSingleComponentSampleというコンポーネントはSPAで実装されており、内部に複数のページ要素が含まれています。
コードも700行以上あり、初見では「これどういう機能持ってるの..?」と困惑すると思います。
もしこの量のコードがレビューで流れてきたことを想像すると、ゾッとしますね。
これに対して先ほどのように
「(対象のコードを選択して)コードが少し冗長なのでもう少し理解しやすいようにリファクタリングして。」
と指示を出してみます。
また、今回使用したツールはCodexで、モデルはGPT-5.1-Codexです。
すると以下のようなコードになりました。
曖昧な指示を出した場合(※文字数多いので気をつけて開いてください)
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
type Priority = 'low' | 'mid' | 'high';
type Status = 'open' | 'in_progress' | 'done';
type Item = {
id: string;
title: string;
description: string;
status: Status;
priority: Priority;
createdAt: number;
updatedAt: number;
tags: string[];
};
type ViewMode = 'list' | 'grid';
type MenuKey = 'dashboard' | 'items' | 'settings';
type ToastType = 'info' | 'error';
type Toast = {
type: ToastType;
message: string;
};
type FormState = {
title: string;
desc: string;
status: Status;
priority: Priority;
tagsText: string;
};
type FormErrors = {
title?: string;
desc?: string;
tags?: string;
};
const priorityRank: Record<Priority, number> = { low: 1, mid: 2, high: 3 };
const statusRank: Record<Status, number> = { open: 1, in_progress: 2, done: 3 };
const menuItems: MenuKey[] = ['dashboard', 'items', 'settings'];
const createDefaultFormState = (): FormState => ({
title: '',
desc: '',
status: 'open',
priority: 'mid',
tagsText: '',
});
const parseTags = (value: string) =>
value
.split(',')
.map((x) => x.trim())
.filter(Boolean);
const validateForm = (form: FormState) => {
const nextErrors: FormErrors = {};
const trimmedTitle = form.title.trim();
const tags = parseTags(form.tagsText);
if (!trimmedTitle) nextErrors.title = 'title required';
else if (trimmedTitle.length > 60) nextErrors.title = 'title too long';
if (form.desc.length > 240) nextErrors.desc = 'desc too long (max 240)';
if (tags.length > 8) nextErrors.tags = 'too many tags (max 8)';
else if (tags.some((t) => t.length > 20)) nextErrors.tags = 'tag too long (max 20)';
return { errors: nextErrors, tags, trimmedTitle };
};
function randId() {
// わざと微妙(衝突しやすい)
return String(Math.floor(Math.random() * 99999999));
}
function now() {
return Date.now();
}
export default function BigSingleComponentSample() {
// --- header/menu state ---
const [menuOpen, setMenuOpen] = useState(false);
const [activeMenu, setActiveMenu] = useState<MenuKey>('items');
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [viewMode, setViewMode] = useState<ViewMode>('list');
// --- data state ---
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
// --- filters/search/sort/paging ---
const [q, setQ] = useState('');
const [statusFilter, setStatusFilter] = useState<Status | 'all'>('all');
const [priorityFilter, setPriorityFilter] = useState<Priority | 'all'>('all');
const [tagQuery, setTagQuery] = useState('');
const [sortKey, setSortKey] = useState<'updatedAt' | 'createdAt' | 'title' | 'status' | 'priority'>('updatedAt');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
// --- form state (create/edit) ---
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(createDefaultFormState());
const [errors, setErrors] = useState<FormErrors>({});
// --- toast / misc ---
const [toast, setToast] = useState<Toast | null>(null);
const toastTimer = useRef<number | null>(null);
const pushToast = useCallback((type: ToastType, message: string) => setToast({ type, message }), []);
const updateForm = (patch: Partial<FormState>) => {
const next = { ...form, ...patch };
setForm(next);
setErrors(validateForm(next).errors);
};
const resetFormState = () => {
setEditingId(null);
setForm(createDefaultFormState());
setErrors({});
};
const startEditing = (item: Item) => {
setEditingId(item.id);
setForm({
title: item.title,
desc: item.description,
status: item.status,
priority: item.priority,
tagsText: item.tags.join(', '),
});
setErrors({});
pushToast('info', 'Editing...');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const deleteItem = (id: string) => {
if (!window.confirm('Delete?')) return;
setItems(items.filter((x) => x.id !== id));
setDirty(true);
pushToast('info', 'Deleted');
};
const handleSubmit = () => {
const { errors: nextErrors, tags, trimmedTitle } = validateForm(form);
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) {
pushToast('error', 'Fix errors first');
return;
}
if (editingId) {
const idx = items.findIndex((x) => x.id === editingId);
if (idx < 0) {
pushToast('error', 'Not found');
setEditingId(null);
return;
}
const next = items.slice();
next[idx] = {
...next[idx],
title: trimmedTitle,
description: form.desc,
status: form.status,
priority: form.priority,
tags,
updatedAt: now(),
};
setItems(next);
pushToast('info', 'Updated');
} else {
const it: Item = {
id: randId(),
title: trimmedTitle,
description: form.desc,
status: form.status,
priority: form.priority,
tags,
createdAt: now(),
updatedAt: now(),
};
setItems([it, ...items]);
pushToast('info', 'Created');
}
setDirty(true);
resetFormState();
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// ======================
// effects (ちょい雑)
// ======================
useEffect(() => {
// settings load
try {
const raw = localStorage.getItem('PE_SETTINGS2');
if (raw) {
const s = JSON.parse(raw) as any;
if (s.theme) setTheme(s.theme);
if (s.viewMode) setViewMode(s.viewMode);
if (s.pageSize) setPageSize(s.pageSize);
}
} catch {}
// items load
setLoading(true);
setTimeout(() => {
try {
const raw = localStorage.getItem('PE_ITEMS2');
const list = raw ? (JSON.parse(raw) as Item[]) : [];
setItems(list);
pushToast('info', `Loaded ${list.length} items`);
} catch (e: any) {
pushToast('error', e?.message ?? 'Load failed');
} finally {
setLoading(false);
}
}, 400);
// cleanup toast timer
return () => {
if (toastTimer.current) window.clearTimeout(toastTimer.current);
};
}, [pushToast]);
useEffect(() => {
// save settings (頻繁に保存されがち)
try {
localStorage.setItem('PE_SETTINGS2', JSON.stringify({ theme, viewMode, pageSize }));
} catch {}
}, [theme, viewMode, pageSize]);
useEffect(() => {
// filter変化でpageリセット(依存が過剰)
setPage(1);
}, [q, statusFilter, priorityFilter, tagQuery, sortKey, sortDir, pageSize]);
useEffect(() => {
// auto toast hide
if (!toast) return;
if (toastTimer.current) window.clearTimeout(toastTimer.current);
toastTimer.current = window.setTimeout(() => setToast(null), 2000);
}, [toast]);
useEffect(() => {
// autosave(雑:items全部依存)
if (!dirty) return;
setSaving(true);
const t = window.setTimeout(() => {
try {
localStorage.setItem('PE_ITEMS2', JSON.stringify(items));
pushToast('info', 'Autosaved');
setDirty(false);
} catch (e: any) {
pushToast('error', e?.message ?? 'Save failed');
} finally {
setSaving(false);
}
}, 800);
return () => window.clearTimeout(t);
}, [items, dirty, pushToast]);
// ======================
// derived data
// ======================
const filtered = useMemo(() => {
let list = items.slice();
const qq = q.trim().toLowerCase();
if (qq) {
list = list.filter((it) => {
const hay = `${it.title} ${it.description} ${it.status} ${it.priority} ${it.tags.join(' ')}`.toLowerCase();
return hay.includes(qq);
});
}
if (statusFilter !== 'all') list = list.filter((it) => it.status === statusFilter);
if (priorityFilter !== 'all') list = list.filter((it) => it.priority === priorityFilter);
const tq = tagQuery.trim().toLowerCase();
if (tq) list = list.filter((it) => it.tags.some((t) => t.toLowerCase().includes(tq)));
list.sort((a, b) => {
const dir = sortDir === 'asc' ? 1 : -1;
if (sortKey === 'updatedAt') return (a.updatedAt - b.updatedAt) * dir;
if (sortKey === 'createdAt') return (a.createdAt - b.createdAt) * dir;
if (sortKey === 'title') return a.title.localeCompare(b.title) * dir;
if (sortKey === 'status') return (statusRank[a.status] - statusRank[b.status]) * dir;
if (sortKey === 'priority') return (priorityRank[a.priority] - priorityRank[b.priority]) * dir;
return 0;
});
return list;
}, [items, q, statusFilter, priorityFilter, tagQuery, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(Math.max(page, 1), totalPages);
const pageItems = filtered.slice((safePage - 1) * pageSize, safePage * pageSize);
// ======================
// ui helpers (わざと関数化しない方針なのでここも直書き寄り)
// ======================
const rootStyle: React.CSSProperties = {
fontFamily: 'system-ui, -apple-system, sans-serif',
padding: 14,
background: theme === 'dark' ? '#111' : '#fafafa',
color: theme === 'dark' ? '#eee' : '#111',
minHeight: '100vh',
};
// ======================
// render
// ======================
return (
<div style={rootStyle}>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
border: '1px solid #888',
borderRadius: 10,
background: theme === 'dark' ? '#1a1a1a' : '#fff',
position: 'sticky',
top: 10,
zIndex: 10,
}}
>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button onClick={() => setMenuOpen((v) => !v)}>{menuOpen ? 'Close' : 'Menu'}</button>
<div>
<div style={{ fontWeight: 800 }}>BigSingleComponentSample</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>header + menu + logic all-in-one</div>
</div>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<select value={viewMode} onChange={(e) => setViewMode(e.target.value as ViewMode)}>
<option value='list'>List</option>
<option value='grid'>Grid</option>
</select>
<button onClick={() => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))}>Theme: {theme}</button>
<button
onClick={() => {
// わざと雑なseed
const seed: Item[] = Array.from({ length: 12 }).map((_, i) => {
const st: Status = i % 3 === 0 ? 'open' : i % 3 === 1 ? 'in_progress' : 'done';
const pr: Priority = i % 3 === 0 ? 'high' : i % 3 === 1 ? 'mid' : 'low';
const t = now() - i * 1000 * 60 * 60 * 3;
return {
id: randId(),
title: `Item ${i + 1}`,
description: i % 2 === 0 ? 'Lorem ipsum dolor sit amet, consectetur.'.repeat(2) : 'Short desc',
status: st,
priority: pr,
tags: i % 2 === 0 ? ['work', 'urgent'] : ['personal', 'misc'],
createdAt: t,
updatedAt: t + 1000 * 60 * 12,
};
});
setItems(seed);
setDirty(true);
pushToast('info', 'Seeded data');
}}
disabled={loading || saving}
>
Seed
</button>
<button
onClick={() => {
// manual save
setSaving(true);
setTimeout(() => {
try {
localStorage.setItem('PE_ITEMS2', JSON.stringify(items));
setDirty(false);
pushToast('info', 'Saved');
} catch (e: any) {
pushToast('error', e?.message ?? 'Save failed');
} finally {
setSaving(false);
}
}, 250);
}}
disabled={loading || saving}
>
Save
</button>
</div>
</div>
{/* Toast */}
{toast && (
<div
style={{
marginTop: 10,
padding: 10,
borderRadius: 10,
border: '1px solid #777',
background: toast.type === 'error' ? '#ffd6d6' : '#d9ffd9',
color: '#111',
}}
>
<b>{toast.type.toUpperCase()}</b> — {toast.message}
</div>
)}
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
{/* Side menu */}
{menuOpen && (
<div
style={{
width: 220,
border: '1px solid #888',
borderRadius: 10,
padding: 10,
background: theme === 'dark' ? '#1a1a1a' : '#fff',
height: 'fit-content',
position: 'sticky',
top: 82,
}}
>
<div style={{ fontWeight: 800, marginBottom: 8 }}>Menu</div>
{menuItems.map((k) => (
<button
key={k}
onClick={() => setActiveMenu(k)}
style={{
width: '100%',
textAlign: 'left',
marginBottom: 6,
padding: '8px 10px',
borderRadius: 8,
border: '1px solid #777',
background: activeMenu === k ? (theme === 'dark' ? '#333' : '#eee') : 'transparent',
color: theme === 'dark' ? '#eee' : '#111',
}}
>
{k}
</button>
))}
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 10 }}>
status: {loading ? 'loading' : saving ? 'saving' : dirty ? 'dirty' : 'clean'}
</div>
</div>
)}
{/* Main */}
<div style={{ flex: 1 }}>
{/* content header */}
<div
style={{
border: '1px solid #888',
borderRadius: 10,
padding: 12,
background: theme === 'dark' ? '#1a1a1a' : '#fff',
marginBottom: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 900, fontSize: 18 }}>
{activeMenu === 'dashboard' ? 'Dashboard' : activeMenu === 'items' ? 'Items' : 'Settings'}
</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>
{activeMenu === 'items' ? `${filtered.length} results` : 'This is intentionally messy'}
</div>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input
placeholder='Search...'
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ padding: 8, borderRadius: 8, border: '1px solid #777' }}
/>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as any)}>
<option value='all'>status: all</option>
<option value='open'>open</option>
<option value='in_progress'>in_progress</option>
<option value='done'>done</option>
</select>
<select value={priorityFilter} onChange={(e) => setPriorityFilter(e.target.value as any)}>
<option value='all'>priority: all</option>
<option value='low'>low</option>
<option value='mid'>mid</option>
<option value='high'>high</option>
</select>
</div>
</div>
{activeMenu === 'items' && (
<div style={{ display: 'flex', gap: 10, marginTop: 10, flexWrap: 'wrap' }}>
<input
placeholder='tag contains...'
value={tagQuery}
onChange={(e) => setTagQuery(e.target.value)}
style={{ padding: 8, borderRadius: 8, border: '1px solid #777' }}
/>
<select value={sortKey} onChange={(e) => setSortKey(e.target.value as any)}>
<option value='updatedAt'>sort: updatedAt</option>
<option value='createdAt'>createdAt</option>
<option value='title'>title</option>
<option value='status'>status</option>
<option value='priority'>priority</option>
</select>
<select value={sortDir} onChange={(e) => setSortDir(e.target.value as any)}>
<option value='desc'>desc</option>
<option value='asc'>asc</option>
</select>
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
<option value={6}>pageSize: 6</option>
<option value={8}>8</option>
<option value={12}>12</option>
</select>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={safePage <= 1}>
Prev
</button>
<span style={{ fontSize: 12, opacity: 0.8 }}>
Page {safePage}/{totalPages}
</span>
<button onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={safePage >= totalPages}>
Next
</button>
</div>
</div>
)}
</div>
{/* Dashboard / Settings dummy */}
{activeMenu !== 'items' ? (
<div
style={{
border: '1px solid #888',
borderRadius: 10,
padding: 12,
background: theme === 'dark' ? '#1a1a1a' : '#fff',
}}
>
<div style={{ fontWeight: 800, marginBottom: 6 }}>Not much here</div>
<div style={{ fontSize: 13, opacity: 0.85 }}>
This page exists to add menu complexity. Try switching tabs and see state interactions.
</div>
</div>
) : (
<>
{/* Form */}
<div
style={{
border: '1px solid #888',
borderRadius: 10,
padding: 12,
background: theme === 'dark' ? '#1a1a1a' : '#fff',
marginBottom: 12,
}}
>
<div style={{ fontWeight: 900, marginBottom: 8 }}>{editingId ? 'Edit item' : 'Create item'}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<div style={{ fontSize: 12, opacity: 0.8 }}>Title</div>
<input
value={form.title}
onChange={(e) => updateForm({ title: e.target.value })}
style={{ width: '100%', padding: 8, borderRadius: 8, border: '1px solid #777' }}
/>
{errors.title && <div style={{ color: 'crimson', fontSize: 12 }}>{errors.title}</div>}
</div>
<div>
<div style={{ fontSize: 12, opacity: 0.8 }}>Tags (comma)</div>
<input
value={form.tagsText}
onChange={(e) => updateForm({ tagsText: e.target.value })}
style={{ width: '100%', padding: 8, borderRadius: 8, border: '1px solid #777' }}
/>
{errors.tags && <div style={{ color: 'crimson', fontSize: 12 }}>{errors.tags}</div>}
</div>
<div style={{ gridColumn: '1 / span 2' }}>
<div style={{ fontSize: 12, opacity: 0.8 }}>Description</div>
<textarea
value={form.desc}
onChange={(e) => updateForm({ desc: e.target.value })}
style={{ width: '100%', padding: 8, borderRadius: 8, border: '1px solid #777', minHeight: 70 }}
/>
{errors.desc && <div style={{ color: 'crimson', fontSize: 12 }}>{errors.desc}</div>}
</div>
<div>
<div style={{ fontSize: 12, opacity: 0.8 }}>Status</div>
<select value={form.status} onChange={(e) => updateForm({ status: e.target.value as Status })} style={{ width: '100%' }}>
<option value='open'>open</option>
<option value='in_progress'>in_progress</option>
<option value='done'>done</option>
</select>
</div>
<div>
<div style={{ fontSize: 12, opacity: 0.8 }}>Priority</div>
<select
value={form.priority}
onChange={(e) => updateForm({ priority: e.target.value as Priority })}
style={{ width: '100%' }}
>
<option value='low'>low</option>
<option value='mid'>mid</option>
<option value='high'>high</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 10 }}>
<button onClick={handleSubmit} disabled={loading || saving}>
{editingId ? 'Update' : 'Create'}
</button>
<button
onClick={() => {
resetFormState();
pushToast('info', 'Reset');
}}
disabled={loading || saving}
>
Reset
</button>
</div>
</div>
{/* List */}
<div
style={{
border: '1px solid #888',
borderRadius: 10,
padding: 12,
background: theme === 'dark' ? '#1a1a1a' : '#fff',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ fontWeight: 900 }}>Items</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>
showing {pageItems.length}/{filtered.length}
</div>
</div>
{loading ? (
<div style={{ opacity: 0.8 }}>Loading...</div>
) : pageItems.length === 0 ? (
<div style={{ opacity: 0.8 }}>No items</div>
) : viewMode === 'grid' ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }}>
{pageItems.map((it) => (
<div key={it.id} style={{ border: '1px solid #777', borderRadius: 10, padding: 10 }}>
<div style={{ fontWeight: 800 }}>{it.title}</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>
{it.status} / {it.priority}
</div>
<div style={{ marginTop: 6, fontSize: 13 }}>{it.description}</div>
<div style={{ marginTop: 6, fontSize: 12, opacity: 0.8 }}>tags: {it.tags.join(', ') || '-'}</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button onClick={() => startEditing(it)}>
Edit
</button>
<button onClick={() => deleteItem(it.id)}>
Delete
</button>
</div>
</div>
))}
</div>
) : (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{pageItems.map((it) => (
<li key={it.id} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 800 }}>{it.title}</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>
{it.status} / {it.priority} — updated {new Date(it.updatedAt).toLocaleString()}
</div>
<div style={{ marginTop: 4, fontSize: 13 }}>{it.description}</div>
<div style={{ marginTop: 4, fontSize: 12, opacity: 0.8 }}>tags: {it.tags.join(', ') || '-'}</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<button onClick={() => startEditing(it)} disabled={saving}>
Edit
</button>
<button onClick={() => deleteItem(it.id)} disabled={saving}>
Delete
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</>
)}
</div>
</div>
<div style={{ marginTop: 12, fontSize: 12, opacity: 0.75 }}>
※これは「プロンプトでリファクタ結果が変わる」検証用に、あえて雑に詰め込んであります。
</div>
</div>
);
}
中身を見てみると、AIは「コンポーネントはそのままに、読みやすく整える」ことはしてくれましたが、結局行数で言うとあまり変わらずでした。
本当なら「ページ単位でコンポーネント分けて欲しいな」とか「useStateのところ、もうちょっと簡単にならないかな」とか、その辺りを整理してくれると嬉しかったり。。
じゃあどうするのか。
ーーー
以下のように指示を出してみます。
(対象コードを選択して)この部分のコードについて、以下のようにリファクタリングして下さい。
・ヘッダーとメニューは別のページでも使いたいので、componentsというディレクトリを新規作成して、そこに切り出して下さい。
・サイドメニューに存在する「dashboard」「Items」「settings」で切り替えられる各ページはそれぞれ別ファイルで定義して下さい
・各ページで仕様されている、入力フォーム、セレクトボックス、ボタンなどのコンポーネントは共通化して、componentsディレクトリの下にそれぞれファイル別に定義して下さい。
・入力フォームに対して全てuseStateで定義されているので、useReducerを用いて一つのオブジェクトとして状態を管理して下さい。
すると以下のようなコードになりました。
設計を考えて指示した場合(※文字数多いので気をつけて開いてください)
結果、BigSingleComponentSampleは750行程度から350行程度になり、コンポーネントも指示した通りにある程度分割されました。useStateも指示通り、useReducer使って実装してくれましたね。
ツール選定のところで最も良いと思っていたCodexでさえもこれだけの差が出てしまったので、 やはりAIに出す指示(プロンプト)はかなり重要だったと思えます。
まとめ
今回は、ここ1〜2年でAIコーディングが一気に実務に入り込んできた中で、
自分が「AIにいい感じに実装してもらう」ために試行錯誤してきたことをまとめました。
ここ1~2年、AIのモデルやツールはかなり進化してきていて、実務でも普通に使える場面が増えてきたなと感じています。
実際、うまくハマると「これ、AIに全部やらせればいいじゃん!」と思うこともあります。
ただ一方で、ツール選定の観点では一番良いと感じていた Codex であっても、
指示の出し方次第でアウトプットにかなり差が出る
というのが、正直なところでした。
なので大切なのは「まだAIは実務で使えない」のではなく、「AIが判断できるように指示を出してあげる」ことだと個人的に思います。
たまに「AIはまだまだジュニアエンジニアレベルだ」という話を聞きますが、自分の感覚としては
AIをジュニアエンジニアだと認識した上で、
仕事がしやすいように指示を出してあげる必要がある
くらいの距離感がしっくりきます。
背景・目的・要件・設計を整理して渡せば、AIはかなり優秀に働いてくれますが、
逆にその整理を省略したまま「いい感じに実装して」と頼むのは、人に仕事を依頼する場合でも難しいはずです。
そう考えると、AIもその辺りは人間とあまり変わらないかもしれませんね。