Overview
Need to walk a directory tree in a React Native Expo app and list every subfolder and file? This guide shows how to do it with expo-file-system, plus Android external storage via StorageAccessFramework (SAF).
Supported targets:
- iOS/Android sandbox: FileSystem.documentDirectory, FileSystem.cacheDirectory
- Android external storage (user-picked folder): StorageAccessFramework
Quick summary: read entries with readDirectoryAsync, stat each entry with getInfoAsync, recurse into directories, and collect results.
Quickstart
- Install dependency
- expo install expo-file-system
- Choose a root directory
- Sandboxed (works on iOS and Android): FileSystem.documentDirectory
- Android external folder: ask the user via StorageAccessFramework
- Recursively walk the tree
- List names, build child URIs, stat each, recurse into directories
- Render in your component or process as needed
Minimal working example (sandboxed app directory)
The example below creates a tiny folder tree in the app’s document directory, then recursively lists everything.
import React, { useEffect, useState } from 'react';
import { Button, ScrollView, Text, View } from 'react-native';
import * as FileSystem from 'expo-file-system';
// Join a child name onto a base URI safely
const joinUri = (base: string, name: string) => (base.endsWith('/') ? base + name : base + '/' + name);
// Recursively list a directory tree
// Returns an array of { type: 'file'|'dir', uri: string, size?: number|null }
async function listRecursive(uri: string): Promise<Array<{type: 'file'|'dir'; uri: string; size?: number|null}>> {
const names = await FileSystem.readDirectoryAsync(uri);
// Stat all entries in parallel (moderate batch size is safer for very large trees)
const entries = await Promise.all(
names.map(async (name) => {
const childUri = joinUri(uri, name);
const info = await FileSystem.getInfoAsync(childUri);
return { name, childUri, info } as const;
})
);
const out: Array<{type: 'file'|'dir'; uri: string; size?: number|null}> = [];
for (const { childUri, info } of entries) {
if (!info.exists) continue;
if (info.isDirectory) {
out.push({ type: 'dir', uri: childUri });
const sub = await listRecursive(childUri);
out.push(...sub);
} else {
out.push({ type: 'file', uri: childUri, size: info.size ?? null });
}
}
return out;
}
export default function App() {
const [items, setItems] = useState<Array<{type: 'file'|'dir'; uri: string; size?: number|null}>>([]);
const root = FileSystem.documentDirectory!; // ends with '/'
// Optional: create a sample tree once
useEffect(() => {
(async () => {
const a = joinUri(root, 'demo');
const b = joinUri(a, 'nested');
await FileSystem.makeDirectoryAsync(a, { intermediates: true }).catch(() => {});
await FileSystem.makeDirectoryAsync(b, { intermediates: true }).catch(() => {});
await FileSystem.writeAsStringAsync(joinUri(a, 'hello.txt'), 'Hello');
await FileSystem.writeAsStringAsync(joinUri(b, 'world.txt'), 'World');
})();
}, []);
const scan = async () => {
const all = await listRecursive(root);
setItems(all);
};
return (
<View style={{ paddingTop: 60, paddingHorizontal: 16, flex: 1 }}>
<Button title="Scan documentDirectory" onPress={scan} />
<ScrollView style={{ marginTop: 12 }}>
{items.map((it, i) => (
<Text key={i} style={{ fontFamily: 'Courier', marginBottom: 4 }}>
{it.type === 'dir' ? 'DIR ' : 'FILE'} {it.uri}{it.type === 'file' && it.size != null ? ` (${it.size} B)` : ''}
</Text>
))}
</ScrollView>
</View>
);
}
Notes:
- The example walks FileSystem.documentDirectory, which is always accessible.
- getInfoAsync returns exists, isDirectory, size (if available), and uri.
Android: Recursively list a user-picked external folder (SAF)
On Android, to access outside the app sandbox (e.g., Downloads), use StorageAccessFramework to let the user pick a directory.
import * as FileSystem from 'expo-file-system';
const { StorageAccessFramework } = FileSystem;
const isDir = async (uri: string) => {
try {
const info = await FileSystem.getInfoAsync(uri);
return info.exists && info.isDirectory === true;
} catch {
return false;
}
};
async function listRecursiveSAF(dirUri: string): Promise<string[]> {
const children = await StorageAccessFramework.readDirectoryAsync(dirUri);
// children are content:// URIs
const out: string[] = [];
// Batch stat checks
const infoList = await Promise.all(children.map((u) => FileSystem.getInfoAsync(u).catch(() => ({ exists: false }))));
for (let i = 0; i < children.length; i++) {
const child = children[i];
const info: any = infoList[i];
if (!info || !info.exists) continue;
if (info.isDirectory) {
out.push(child);
const sub = await listRecursiveSAF(child);
out.push(...sub);
} else {
out.push(child);
}
}
return out;
}
export async function pickAndList() {
const perm = await StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!perm.granted) return [];
return listRecursiveSAF(perm.directoryUri);
}
Tip: SAF URIs are not normal file:// paths. Keep them as returned and pass them back to expo-file-system APIs.
Step-by-step
- Install
- expo install expo-file-system
- Decide scope
- App sandbox (documentDirectory/cacheDirectory) or external storage (Android SAF)
- Implement recursion
- readDirectoryAsync(root) → names/URIs
- For each entry: getInfoAsync(child)
- If isDirectory, recurse; else collect file
- Render or process
- Map over results or filter by extension/size
Common pitfalls
- Missing trailing slash: documentDirectory usually ends with '/', but other URIs may not. Normalize when joining.
- iOS external storage: Not supported. You can only access the app sandbox.
- Android external storage: Use StorageAccessFramework; legacy READ/WRITE permissions won’t help in Managed Expo.
- Large trees: Unbounded parallel getInfoAsync calls can be slow or memory heavy. Batch them.
- Asset/bundle files: App bundle assets are not enumerable via expo-file-system; use expo-asset or embed metadata.
- File info variability: size or modificationTime may be null/undefined on some platforms/URIs.
- Nonexistent roots: Always check getInfoAsync(root).exists before scanning.
Performance notes
- Batch and limit concurrency: Process entries in chunks (e.g., size 16–64) to avoid overwhelming the bridge.
- Short-circuit with filters: Pass a predicate (e.g., allowed extensions) and skip recursion into folders you don’t need.
- Avoid MD5: Don’t request md5 in getInfoAsync unless you actually need it.
- Shallow scans first: Collect directory structure first, then drill down selectively.
- Cache results: Persist a snapshot (e.g., JSON in documentDirectory) and rescan incrementally.
Example: batched stat helper
async function statBatch(uris: string[], batchSize = 32) {
const out: any[] = [];
for (let i = 0; i < uris.length; i += batchSize) {
const slice = uris.slice(i, i + batchSize);
const infos = await Promise.all(slice.map((u) => FileSystem.getInfoAsync(u).catch(() => ({ exists: false }))));
out.push(...infos);
}
return out;
}
Where can I list files?
Location | Works on | How |
---|---|---|
documentDirectory | iOS, Android | FileSystem.readDirectoryAsync + getInfoAsync |
cacheDirectory | iOS, Android | Same as above |
External storage (Downloads, etc.) | Android | StorageAccessFramework after user picks a folder |
FAQ
How do I list the app bundle files?
- You generally can’t enumerate bundle contents. Bundle assets are compiled; track them via code or use expo-asset for known resources.
Do I need permissions for documentDirectory?
- No. It’s inside your app sandbox.
Can I scan the entire device on Android?
- Not automatically. You must ask the user to pick a directory via SAF, then you can traverse within that tree.
Why do I get undefined size?
- Some URIs don’t expose size reliably. Treat it as optional.
How do I stop a long scan?
- Add an abort flag or pass a maximum depth and check it before recursing.
Summary
- Use expo-file-system to traverse sandboxed directories on iOS and Android.
- On Android, use StorageAccessFramework for user-picked external folders.
- Recurse with readDirectoryAsync + getInfoAsync, and apply batching, filtering, and caching to keep scans fast and responsive.