一開始就獲取Firebase storage初始值
一開始就獲取 Firebase Storage 初始值
TL;DRComponent mount 後 Firebase async 才回資料,首次 render 沒值 是常見問題。標準解:用 loading state 等待。現代做法:Suspense + RSC 直接 await、或 TanStack Query / SWR 自動處理 loading 狀態。
問題情境
function FileList() {
const [files, setFiles] = useState([]);
useEffect(() => {
listAll(ref(storage, 'images')).then(async (res) => {
const urls = await Promise.all(res.items.map((it) => getDownloadURL(it)));
setFiles(urls);
});
}, []);
return <div>{files.map((url) => <img src={url} />)}</div>;
// ⚠️ 第一次 render 時 files === [],畫面空白(或閃爍)
}
典型 async data fetching pitfall:component mount → render → useEffect 才跑 → fetch → set state → re-render。中間 100-500ms 是空畫面。
解法 1:loading state(經典寫法)
import { getStorage, ref, listAll, getDownloadURL } from 'firebase/storage';
import { useEffect, useState } from 'react';
interface FileItem {
url: string;
name: string;
}
function useFirebaseFiles(path: string) {
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
const storage = getStorage();
const listRef = ref(storage, path);
listAll(listRef)
.then(async (res) => {
const items = await Promise.all(
res.items.map(async (item) => ({
name: item.name,
url: await getDownloadURL(item),
}))
);
if (!cancelled) setFiles(items);
})
.catch((err) => {
if (!cancelled) setError(err);
console.error('Error listing files:', err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; }; // ⭐ 避免 unmounted setState
}, [path]);
return { files, loading, error };
}
function FileList() {
const { files, loading, error } = useFirebaseFiles('images');
if (loading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
if (files.length === 0) return <p>沒有檔案</p>;
return (
<div>
{files.map((f) => <img key={f.name} src={f.url} alt={f.name} />)}
</div>
);
}
重點:
cancelledflag 處理 component unmount 期間 fetch 還沒結束的 race conditionerrorstate 給 fallback UI- 空陣列 vs loading 區分清楚(
loading是還沒查、files === []是查過了沒檔案)
解法 2:TanStack Query(實務首選)
import { useQuery } from '@tanstack/react-query';
import { getStorage, ref, listAll, getDownloadURL } from 'firebase/storage';
function useFirebaseFiles(path: string) {
return useQuery({
queryKey: ['firebase-files', path],
queryFn: async () => {
const res = await listAll(ref(getStorage(), path));
return Promise.all(res.items.map(async (it) => ({
name: it.name,
url: await getDownloadURL(it),
})));
},
staleTime: 5 * 60_000, // 5 分鐘內不重新 fetch
});
}
function FileList() {
const { data: files = [], isLoading, error } = useFirebaseFiles('images');
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error as Error} />;
return <div>{files.map((f) => <img key={f.name} src={f.url} />)}</div>;
}
自動處理 loading / error / cache / refetch / 多元件共享 query。1/3 程式碼,功能多 5 倍。
解法 3:Server Component(Next.js App Router)
2026 年最乾淨寫法:把資料 fetch 直接寫在 Server Component:
// app/files/page.tsx — 這是 Server Component
import { adminStorage } from '@/lib/firebase-admin';
async function getFiles() {
const [files] = await adminStorage.bucket().getFiles({ prefix: 'images/' });
return Promise.all(files.map(async (f) => ({
name: f.name,
url: (await f.getSignedUrl({ action: 'read', expires: Date.now() + 3600_000 }))[0],
})));
}
export default async function FilesPage() {
const files = await getFiles(); // ⭐ 直接 await,不用 useEffect
return (
<div>
{files.map((f) => <img key={f.name} src={f.url} alt={f.name} />)}
</div>
);
}
零 loading state,server 端就把 HTML 渲染好了。配 Suspense streaming:
<Suspense fallback={<Skeleton />}>
<FilesPage />
</Suspense>
重點:Server Component 用 Firebase Admin SDK,不是 client SDK。要 service account 認證。
預載入策略
如果 知道使用者會去某頁面,可以提前 fetch:
1. router prefetch(Next.js)
import Link from 'next/link';
<Link href="/files" prefetch>檔案列表</Link>
// hover 時自動 prefetch 該頁面 + RSC 資料
2. TanStack Query prefetchQuery
const queryClient = useQueryClient();
<button
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ['firebase-files', 'images'],
queryFn: () => fetchFiles('images'),
});
}}
>
Show Files
</button>
使用者 hover 就先 fetch,點下去資料已經在 cache,瞬開。
常見地雷
1. URL 對外暴露getDownloadURL 拿到的是 包含 access token 的 public URL。外洩等於別人能下載你的檔。解法:
- 用 Storage Rules 控制誰能讀
- 對敏感檔案用 Signed URL with short expiry(Admin SDK)
- 不要把 URL 存進 public DB
2. listAll 可能很慢listAll 沒分頁,如果該路徑下有 1000+ 檔案會撈很久。解法:用
list({ maxResults, pageToken })分頁:const result = await list(listRef, { maxResults: 100 }); // result.items, result.nextPageToken
3. 不必要的 re-listAll每次 component mount 都 fetch,即使資料沒變。解法:TanStack Query 的
staleTime處理。或自己加 cache。
最簡 wrapper(若不想裝 TanStack Query)
// hooks/useAsync.ts
import { useState, useEffect } from 'react';
export function useAsync<T>(fn: () => Promise<T>, deps: any[]) {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: Error | null;
}>({ data: null, loading: true, error: null });
useEffect(() => {
let cancelled = false;
setState((s) => ({ ...s, loading: true }));
fn().then(
(data) => !cancelled && setState({ data, loading: false, error: null }),
(error) => !cancelled && setState({ data: null, loading: false, error }),
);
return () => { cancelled = true; };
}, deps);
return state;
}
function FileList() {
const { data: files, loading, error } = useAsync(async () => {
const res = await listAll(ref(getStorage(), 'images'));
return Promise.all(res.items.map(async (it) => ({
name: it.name,
url: await getDownloadURL(it),
})));
}, []);
if (loading) return <Skeleton />;
if (error) return <Error error={error} />;
return <div>{files?.map((f) => <img key={f.name} src={f.url} />)}</div>;
}
足夠涵蓋 90% 情境,不裝額外套件。