フロントエンド開発のナレッジ&トラブルシューティング。React/Tailwind/ステータス管理/モバイルUI/Web Audio等
React/TypeScript/Tailwindを使ったフロントエンド開発の学びを記録する。
SSEストリーミング処理は /kb-frontend-sse、Amplify UI は /kb-frontend-amplify-ui を参照。
Tailwind CSS v4 には2つの統合方式がある。通常は Vite プラグイン方式(推奨)を使うが、dev サーバーで動作しない場合は PostCSS 方式にフォールバックする。
| 方式 | パッケージ | 仕組み | 推奨度 |
|---|---|---|---|
| Vite プラグイン | @tailwindcss/vite | Vite の transform フックで CSS を処理 | 公式推奨 |
| PostCSS | @tailwindcss/postcss | Vite 組み込みの CSS パイプライン経由 | フォールバック |
// vite.config.ts
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
// postcss.config.js
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
// vite.config.ts - tailwindcss プラグインは不要
export default defineConfig({
plugins: [react()],
})
ブラウザで CSS を確認し、先頭に /*! tailwindcss v4.x.x | MIT License */ が表示されていれば正常。
/* src/index.css */
@import "tailwindcss";
@theme {
--color-brand-blue: #0e0d6a;
}
// NG: シャローコピーしてオブジェクト直接変更 → StrictModeで2回実行され文字がダブる
setMessages(prev => {
const newArr = [...prev];
newArr[newArr.length - 1].content += chunk;
return newArr;
});
// OK: map + スプレッド構文でイミュータブルに更新
setMessages(prev =>
prev.map((msg, idx) =>
idx === prev.length - 1 && msg.role === 'assistant'
? { ...msg, content: msg.content + chunk }
: msg
)
);
// NG: 条件レンダリングだとアンマウント時に状態が消える
{activeTab === 'chat' ? <Chat /> : <Preview />}
// OK: hiddenクラスで非表示にすれば状態が保持される
<div className={activeTab === 'chat' ? '' : 'hidden'}>
<Chat />
</div>
<div className={activeTab === 'preview' ? '' : 'hidden'}>
<Preview />
</div>
<div
key={isSearching ? `search-${statusText}` : index}
className={`status-box ${isSearching ? 'animate-fade-in' : ''}`}
>
{statusText}
</div>
iOS Safariでは:hoverがタップで正しく動作しない。
// NG: CSS hoverベース(iOSで動作しない)
<div className="relative group">
<button>メニュー ▼</button>
<div className="opacity-0 invisible group-hover:opacity-100 group-hover:visible">...</div>
</div>
// OK: useState + onClick ベース
function Dropdown() {
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside); // iOS対応
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, [isOpen]);
return (
<div className="relative" ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>メニュー ▼</button>
{isOpen && (
<div className="absolute right-0 top-full mt-1 bg-white border rounded-lg shadow-lg z-10">
<button onClick={() => { setIsOpen(false); handleOption1(); }}
className="block w-full px-4 py-2 hover:bg-gray-100 active:bg-gray-200">
オプション1
</button>
</div>
)}
</div>
);
}
onToolUse: (toolName) => {
if (toolName === 'output_slide') {
setMessages(prev => {
const hasExisting = prev.some(
msg => msg.isStatus && msg.statusText === 'スライドを生成中...'
);
if (hasExisting) return prev;
return [
...prev,
{ role: 'assistant', content: '', isStatus: true, statusText: 'スライドを生成中...' }
];
});
}
},
前のステータスを完了に更新しつつ、新しいステータスを追加する:
if (toolName === 'output_slide') {
setMessages(prev => {
const updated = prev.map(msg =>
msg.isStatus && msg.statusText === 'Web検索中...'
? { ...msg, statusText: 'Web検索完了' }
: msg
);
return [
...updated,
{ role: 'assistant', content: '', isStatus: true, statusText: 'スライドを生成中...' }
];
});
}
onText: (text) => {
stopTipRotation();
setMessages(prev => {
// テキスト受信時に全ての進行中ステータスを自動完了
let msgs = prev.map(msg => {
if (msg.isStatus && msg.statusText?.startsWith('Web検索中'))
return { ...msg, statusText: 'Web検索完了' };
if (msg.isStatus && msg.statusText?.startsWith('スライドを生成中'))
return { ...msg, statusText: 'スライドを生成しました', tipIndex: undefined };
return msg;
});
return [...msgs, { role: 'assistant', content: text }];
});
}
重要: テキスト受信(onText)はツール完了のシグナルとして機能する。prevをmapした結果は新しい配列。後続処理ではmap結果の変数(msgs)を使うこと。
const streamMessage = async (message: string) => {
setMessages(prev => [...prev, { role: 'assistant', content: '', isStreaming: true }]);
for (const char of message) {
await new Promise(resolve => setTimeout(resolve, 30));
setMessages(prev =>
prev.map((msg, idx) =>
idx === prev.length - 1 && msg.isStreaming
? { ...msg, content: msg.content + char }
: msg
)
);
}
setMessages(prev =>
prev.map((msg, idx) =>
idx === prev.length - 1 && msg.isStreaming
? { ...msg, isStreaming: false }
: msg
)
);
};
コールバック内で疑似ストリーミングを呼ぶ場合、毎回 isStreaming: true を設定してカーソル表示を維持する:
// ✅ 毎回 isStreaming: true を設定
for (const char of message) {
await new Promise(resolve => setTimeout(resolve, 30));
setMessages(prev =>
prev.map((msg, idx) =>
idx === prev.length - 1 && msg.role === 'assistant'
? { ...msg, content: msg.content + char, isStreaming: true }
: msg
)
);
}
onErrorコールバック内でthrow errorしても外側のtry-catchには伝播しない。コールバック内で直接状態を更新する:
// ❌ NG: throw しても外側の catch に届かない
onError: (error) => { throw error; },
// ✅ OK: コールバック内で直接状態を更新
onError: (error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
const isModelNotAvailable = errorMessage.includes('model identifier is invalid');
const displayMessage = isModelNotAvailable
? 'モデルがまだ利用できません。リリースをお待ちください!'
: 'エラーが発生しました。もう一度お試しください。';
streamErrorMessage(displayMessage);
setIsLoading(false);
},
| フレームワーク/ツール | .env | .env.local | 備考 |
|---|---|---|---|
| Vite | ○ | ○ | 両方読む(優先度: .env.local > .env) |
| Next.js | ○ | ○ | 両方読む |
| Node.js dotenv | ○ | × | .env のみ |
Amplify CDK(import 'dotenv/config')とViteの両方で使う場合は .env に統一する。
<!-- OGP -->
<meta property="og:title" content="タイトル" />
<meta property="og:description" content="説明" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://example.com/" />
<meta property="og:image" content="https://example.com/ogp.jpg?v=2" />
<meta property="og:image:secure_url" content="https://example.com/ogp.jpg?v=2" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:type" content="image/jpeg" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@username" />
<meta name="twitter:title" content="タイトル" />
<meta name="twitter:description" content="説明" />
<meta name="twitter:image" content="https://example.com/ogp.jpg?v=2" />
| カード種類 | 表示 | 推奨画像サイズ |
|---|---|---|
summary | 小さい画像が右側 | 512x512(正方形) |
summary_large_image | 大きい画像が上部 | 1200x630(横長) |
from PIL import Image
img = Image.open('original.jpg')
img_clean = Image.new('RGB', img.size)
img_clean.paste(img)
img_clean.save('ogp.jpg', 'JPEG', quality=85)
Tailwind CSS v4のPreflightがlist-style: noneを適用するため、デフォルトで箇条書きの記号が表示されない。
// NG: 行頭記号が表示されない
<ul className="text-sm">
// OK: list-disc list-inside を追加
<ul className="text-sm list-disc list-inside">
/* NG */
.marpit ul { list-style: disc !important; }
/* OK */
.marpit ul { list-style-type: disc !important; }
const [showConfirm, setShowConfirm] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [result, setResult] = useState<Result | null>(null);
const handleConfirm = async () => {
setIsProcessing(true);
try {
const result = await doSomething();
setShowConfirm(false); // 処理完了後に閉じる
setResult(result);
} catch (error) {
setShowConfirm(false);
alert(`エラー: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
ポイント: モーダルを閉じるのは処理完了後。閉じるのが先だと「処理中...」が見えない。
症状: new AudioContext({ sampleRate: 16000 }) で作成した AudioContext で音声再生が不安定(ノイズ、途切れ、無音)
原因: macOS のオーディオハードウェアは通常 48kHz で動作する。16kHz を強制するとドライバレベルで不安定になる
解決策: AudioContext はネイティブサンプルレートで作成し、createBuffer(1, length, 16000) でソースの sampleRate を指定する
// NG: sampleRate を 16kHz に強制
const ctx = new AudioContext({ sampleRate: 16000 });
// OK: ネイティブサンプルレート + AudioBuffer で 16kHz を指定
const ctx = new AudioContext(); // ネイティブ(通常 48kHz)
const buffer = ctx.createBuffer(1, data.length, 16000); // 16kHz として解釈
// Web Audio API が自動でリサンプリング(16kHz → 48kHz)
症状: AudioBufferSourceNode で音声を再生しようとしても無音
原因: ブラウザの自動再生ポリシーにより、ユーザーインタラクションなしでは AudioContext が suspended 状態になる
解決策: ユーザーのボタンクリック等のタイミングで AudioContext.resume() を呼ぶ
const handleStartCall = async () => {
await audioContext.resume(); // 必須!これがないと音が出ない
// ... WebSocket接続等
};
症状: アシスタントの応答が吹き出しで2回表示される
原因: isFinal=false のときだけ直前エントリを上書きしていたため、isFinal=true が来ると新しいエントリとして追加された
解決策: isFinal の値に関わらず、直前エントリが同じロールで isFinal=false なら上書き
setTranscripts(prev => {
const last = prev[prev.length - 1];
if (last && last.role === role && !last.isFinal) {
return [...prev.slice(0, -1), { role, text, isFinal }];
}
return [...prev, { role, text, isFinal }];
});
症状: npx vite の dev サーバーで Tailwind のユーティリティクラスが一切生成されない。ビルドでは正常
原因: @tailwindcss/vite プラグインの transform ハンドラーが Vite 7 の dev サーバーモードで呼ばれない場合がある
解決策: @tailwindcss/postcss(PostCSS 方式)に切り替える(設定例は上記「Tailwind CSS v4」セクション参照)
症状: APIは成功(200)だが、UIに内容が表示されない
原因: APIはevent.dataを返すが、コードはevent.contentを期待していた
解決策: 両方に対応 → const textValue = event.content || event.data;
症状: onErrorコールバックで疑似ストリーミングを開始しても、メッセージが表示されない
原因: finallyブロックが先に実行されisStreaming: falseになるため、ストリーミングループ内のチェックが失敗
解決策: ループ内でisStreamingチェックを削除し、idx === prev.length - 1 && msg.role === 'assistant' のみで判定
症状: シェアリンクをクリックしてTwitterを開いても、テキストが空
原因: https://x.com/compose/post?text=... 形式では text パラメータが無視されることがある
解決策: Twitter Web Intent形式を使用 → https://twitter.com/intent/tweet?text={encoded_text}