onFilesAdded
(newFiles, previousFiles)
A path the previous scan did not have. Filters run first, so a pruned directory never even gets enumerated.
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-fsFolder on disk
Chrome · Edge · Opera, desktop
Browser storage
Chrome · Edge · Opera · Safari · Firefox
Talks to
Nothing. No server, no upload
Licence
MIT
(newFiles, previousFiles)
A path the previous scan did not have. Filters run first, so a pruned directory never even gets enumerated.
(changedFiles, previousFiles)
Same path, different bytes. Contents are only re-read when lastModified or size moved, so a steady tree costs no I/O.
(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
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.
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.
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>);
This browser has no directory picker. The store on the right works here.
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.
const { addOpfsDirectory, files } = useFs();useEffect(() => {// No picker, no prompt, no gesture.// Mounts <opfs>/notes and watches it.addOpfsDirectory({ name: "notes" });}, [addOpfsDirectory]);
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
1import { commonFilters, useFs } from "use-fs";23function Editor() {4 const {5 onDirectorySelection,6 files,7 writeFile,8 isBrowserSupported,9 } = useFs({10 // Prunes build output, drops OS scratch11 // files, honours every .gitignore.12 filters: commonFilters,13 onFilesChanged: (changed) => {14 for (const [path] of changed) {15 console.log("changed", path);16 }17 },18 });1920 if (!isBrowserSupported) {21 return <p>Needs Chrome, Edge or Opera.</p>;22 }2324 return (25 <>26 <button onClick={() => onDirectorySelection()}>27 Open a folder28 </button>29 {Array.from(files, ([path, contents]) => (30 <textarea31 key={path}32 defaultValue={contents}33 onBlur={(e) => writeFile(path, e.target.value)}34 />35 ))}36 </>37 );38}
What one scan does
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.
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.
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
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.
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.
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.
Nothing yet.
Added and deleted files land here, newest first, as each scan resolves.
Every export
State
Actions
Options · default
Filters
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.
import { commonFilters, createFilter } from "use-fs";const onlyTypeScript = createFilter({shouldIncludeFile: ({ name }) => name.endsWith(".ts"),});useFs({ filters: [...commonFilters, onlyTypeScript] });