a React hook for theFile System Access API+OPFS

Watch a folder.
Re-render on change.

Point useFs() at a folder on your machine, or at the browser's own private storage. Your component gets everything inside it, and re-renders the moment a file is added, changed or deleted. No upload, no refresh, no second file dialog.

$npm install use-fs
Full playground
watchingscan 0000 · 300ms
my-project/
src/
index.ts
scan.ts
walk.ts
README.md
.env.local
node_modules/pruned
callbacks · preview
previewNeeds a browser with the File System Access API.

Folder on disk

Chrome · Edge · Opera, desktop

Browser storage

Chrome · Edge · Opera · Safari · Firefox

Talks to

Nothing. No server, no upload

Licence

MIT

onFilesAdded

(newFiles, previousFiles)

A path the previous scan did not have. Filters run first, so a pruned directory never even gets enumerated.

onFilesChanged

(changedFiles, previousFiles)

Same path, different bytes. Contents are only re-read when lastModified or size moved, so a steady tree costs no I/O.

onFilesDeleted

(deletedFiles, previousFiles)

The path is gone. You still get its last contents, so you can archive it, undo it, or put it back.

Two stores, one hook

A folder on disk, or the browser's own.

Both are a FileSystemDirectoryHandle underneath, so the same files map, the same filters and the same writeFile work against either. Watch one, the other, or both at once.

A folder on disk

The user picks a directory and grants access to it. Your app reads and writes the real files — the ones already open in their editor.

picker.tsx
const { onDirectorySelection, files } = useFs();
// The picker only opens from a user gesture,
// so this belongs in an event handler.
return (
<button onClick={() => onDirectorySelection()}>
Open a folder
</button>
);
Browsers
Desktop Chrome, Edge, Opera
Asks
A picker, then a permission prompt to write
Stored
On your disk, where your editor can see them

This browser has no directory picker. The store on the right works here.

Browser storage

The origin private file system: a real directory tree the user never sees, scoped to your origin and kept across reloads. Nothing to approve, so it can mount in an effect.

storage.tsx
const { addOpfsDirectory, files } = useFs();
useEffect(() => {
// No picker, no prompt, no gesture.
// Mounts <opfs>/notes and watches it.
addOpfsDirectory({ name: "notes" });
}, [addOpfsDirectory]);
Browsers
Chrome, Edge, Opera, Safari 17+, Firefox 111+
Asks
Nothing at all
Stored
In the browser, private to this origin

Pass name. The OPFS root is shared with everything else on the origin — WASM databases, other libraries — and mounting it whole means walking all of it on every scan.

The whole integration

One hook, one folder.

editor.tsx
1import { commonFilters, useFs } from "use-fs";
2
3function Editor() {
4 const {
5 onDirectorySelection,
6 files,
7 writeFile,
8 isBrowserSupported,
9 } = useFs({
10 // Prunes build output, drops OS scratch
11 // files, honours every .gitignore.
12 filters: commonFilters,
13 onFilesChanged: (changed) => {
14 for (const [path] of changed) {
15 console.log("changed", path);
16 }
17 },
18 });
19
20 if (!isBrowserSupported) {
21 return <p>Needs Chrome, Edge or Opera.</p>;
22 }
23
24 return (
25 <>
26 <button onClick={() => onDirectorySelection()}>
27 Open a folder
28 </button>
29 {Array.from(files, ([path, contents]) => (
30 <textarea
31 key={path}
32 defaultValue={contents}
33 onBlur={(e) => writeFile(path, e.target.value)}
34 />
35 ))}
36 </>
37 );
38}

What one scan does

Every 300ms, in order.

  1. 01

    Walk

    Breadth-first, with a bounded number of directories open at once. A filter that rejects a directory prunes the whole subtree, so node_modules is never enumerated.

  2. 02

    Stat

    Every discovered file is stat'd first. Contents are re-read only when lastModified or size moved, so polling a large tree at rest does no content I/O.

  3. 03

    Diff

    Added, changed and deleted are resolved against the previous scan. Rendered state is coalesced by debounceInterval; callbacks always fire immediately.

Scans never throw. A directory that cannot be enumerated — permission revoked, folder moved — keeps its last known contents instead of reporting every file as deleted, and the reason is surfaced through error.

Full view

Read it, edit it, write it back.

The same directory the panel at the top is watching — a folder off your disk, or the browser's own storage. Everything runs in this tab; nothing is uploaded, and disk access ends when you close it.

This browser has neither the File System Access API nor the origin private file system. Open the page in a current Chrome, Edge, Opera, Safari or Firefox to use the playground.

0 filespaused
Files

Nothing open yet.

Pick a folder, or mount browser storage — nothing is read until you do. commonFilters skips node_modules, build output and anything your .gitignore lists.

Viewer

No file selected.

Pick one on the left to read it. Change it in your editor and the diff appears here on the next scan.

Events

Nothing yet.

Added and deleted files land here, newest first, as each scan resolves.

Every export

The entire API.

State

files
Map<string, string> of watched files, keyed by path
handles
Map of FileSystemFileHandle, keyed by path
directories
Paths of the watched roots
isProcessing
A scan has run long enough to be worth showing
isPolling
The polling loop is running
isBrowserSupported
The directory picker is available
isOpfsSupported
The origin private file system is available
error
Most recent recoverable error, or null

Actions

onDirectorySelection()
Open the picker and watch the choice
addOpfsDirectory(options?)
Watch browser storage — no prompt, no gesture
addDirectory(handle, options?)
Watch a handle you already hold
removeDirectory(path)
Stop watching, without touching disk
refresh()
Run a scan right now
startPolling() / stopPolling()
Drive the loop by hand
writeFile(path, data, options?)
Write, creating missing parents
createFile(path, initialData?)
Create or open, returns the handle
deleteFile(path)
Delete one file
deleteDirectory(path)
Delete a directory and everything below
requestPermission(mode?)
Re-request access for every root
onClear()
Stop watching everything and reset

Options · default

filters
commonFilters
pollInterval
300
debounceInterval
50
batchSize
50
concurrency
8
mode
"read"
autoStartPolling
true
processingIndicatorDelay
100

Filters

Decide what the hook can see.

commonFilters is the default: it prunes build output, drops .DS_Store and friends, and honours every .gitignore in the tree. Compose your own on top — a filter that rejects a directory prunes the whole subtree.

Also exported: walkDirectory, scanDirectories, toContentMap, normalizePath, isFileSystemAccessSupported, isOpfsSupported, getDirectoryPicker, getOpfsRoot, ensurePermission.

filters.ts
import { commonFilters, createFilter } from "use-fs";
const onlyTypeScript = createFilter({
shouldIncludeFile: ({ name }) => name.endsWith(".ts"),
});
useFs({ filters: [...commonFilters, onlyTypeScript] });