# use-fs > use-fs is a React hook for the File System Access API and the origin private file system (OPFS). Point `useFs()` at a folder the user picks on disk, or at browser-private storage, and the component receives every file inside it as a `Map` and re-renders when a file is added, changed or deleted. It can also write, create and delete files. Nothing is uploaded; there is no server. - [AGENTS.md](https://use-fs.com/AGENTS.md): how a coding agent should use the hook - [Live demo](https://use-fs.com/): open a folder or browser storage and watch events stream in - [Source on GitHub](https://github.com/TimMikeladze/use-fs) - [Package on npm](https://www.npmjs.com/package/use-fs) A React hook for integrating with the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API) and the [origin private file system](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system). Visit [**use-fs.com**](https://use-fs.com) to try it out in your browser. Point the hook at a directory and it watches everything inside it, re-rendering when a file is added, changed or deleted. That directory can be either of two things: - **A folder on disk**, chosen by the user through the directory picker. Read, write and manage real files directly, with no repeated file dialogs and nothing uploaded anywhere. - **The origin private file system (OPFS)**, a private store scoped to your origin. No picker, no permission prompt, and it works in every modern browser. Both are the same `FileSystemDirectoryHandle` underneath, so they are the same hook, the same `files` map and the same writes - only the backing store changes. > Note: The directory picker is desktop Chrome, Edge and Opera only - check `isBrowserSupported`. OPFS works in every current browser - check `isOpfsSupported`, and `isOpfsWritable` before writing from the main thread. See [which store to use](#picker-or-opfs). ## Install ```console npm install use-fs yarn add use-fs pnpm add use-fs ``` ## Getting Started ```tsx import { useFs } from "use-fs"; function App() { const { onDirectorySelection, files, isBrowserSupported, onClear, isProcessing, writeFile, deleteFile, startPolling, stopPolling, isPolling, error, } = useFs({ // Called when new files appear in a watched directory. onFilesAdded: (newFiles, previousFiles) => { // newFiles: Map - path -> contents // previousFiles: Map - the state before this change console.log("Files added:", newFiles); }, // Called when a watched file's contents change. onFilesChanged: (changedFiles, previousFiles) => { console.log("Files changed:", changedFiles); }, // Called when a watched file disappears. The map holds its last contents. onFilesDeleted: (deletedFiles, previousFiles) => { console.log("Files deleted:", deletedFiles); }, }); if (!isBrowserSupported) { return
Browser not supported
; } return (
Status: {isPolling ? "Polling Active" : "Polling Stopped"}
{error &&
{error.message}
} {Array.from(files.entries()).map(([path, content]) => (

{path}

{content}
))}
); } ``` Paths are POSIX-style and always prefixed with the name of the watched root directory, e.g. `my-project/src/index.ts`. Every path passed to `writeFile`, `createFile`, `deleteFile` and `deleteDirectory` must resolve inside a watched directory; `.` and `..` segments are rejected. ## Picker or OPFS? Same hook, same `files` map, same writes. What differs is who owns the bytes and what the browser asks the user first. | | Folder on disk | Browser storage (OPFS) | | --- | --- | --- | | Entry point | `onDirectorySelection()`, or `addDirectory(handle)` | `addOpfsDirectory()` | | Support flag | `isBrowserSupported` | `isOpfsSupported` (read), `isOpfsWritable` (write) | | Browsers | Chrome, Edge, Opera - desktop only | Read: Chrome 86+, Safari 15.2+, Firefox 111+. Write from the hook: Chrome 86+, Safari 26+, Firefox 111+ | | The user sees | A picker, then a permission prompt on first write | Nothing at all | | User gesture | Required - call it from an event handler | Not required - an effect is fine | | Where the bytes are | Real files, wherever the user pointed you | A private store scoped to your origin | | Readable by other apps | Yes - their editor, their terminal, their backups | No - only this origin, only through the API | | Across a reload | Contents survive; access does not. Re-prompt, or persist the handle in IndexedDB | Both survive. Mount it again and it is all there | | Cleared by | The user, in their own file manager | Clearing site data, or eviction under storage pressure | | Capacity | The disk | A quota. `navigator.storage.estimate()` reports it | | Changed behind your back | Constantly - that is the point | Only by your own workers or another tab | **Reach for the picker** when the files are the user's and they already care about them: an editor, a linter, a local-first tool that has to interoperate with git and the rest of their machine. **Reach for OPFS** when the files are your app's: a cache, a scratch workspace, a WASM database, drafts that should survive a reload. Also when you simply need the feature to exist in Safari and Firefox. Nothing forces the choice - the two are watched by the same hook, so you can offer both and let support decide: ```tsx const { isBrowserSupported, isOpfsSupported, onDirectorySelection, addOpfsDirectory, directories, } = useFs(); // Both roots land here, and `files` spans them.

Watching: {directories.join(", ") || "nothing yet"}

``` ## Origin private file system `addOpfsDirectory()` watches the origin private file system instead of a folder on disk. There is no picker, no permission prompt and no user gesture requirement, so it can be called straight from an effect - and it works in Safari and Firefox, which have no directory picker at all. ```tsx import { useEffect } from "react"; import { useFs } from "use-fs"; function Notes() { const { files, addOpfsDirectory, isOpfsSupported, writeFile } = useFs({ // Nothing outside this tab writes to OPFS unless a worker or another tab // does, so drive scans by hand when you own every write. autoStartPolling: false, }); useEffect(() => { if (isOpfsSupported) { // Mounts /notes, creating it if it does not exist. addOpfsDirectory({ name: "notes" }); } }, [isOpfsSupported, addOpfsDirectory]); return ( ); } ``` Everything the hook does works the same way against an OPFS mount: filters, `refresh`, polling, `writeFile`, `createFile`, `deleteFile`, `deleteDirectory`. Both stores can be watched at once - `addOpfsDirectory()` and `onDirectorySelection()` add roots to the same `directories` list. | Option | Default | Description | | --- | --- | --- | | `name` | – | Subdirectory of the OPFS root to mount, e.g. `notes` or `notes/2026` | | `create` | `true` | Create that subdirectory when it does not exist | | `path` | `name`, else `"opfs"` | Path the mount is exposed under | **Pass `name`.** The OPFS root is shared by everything on the origin - SQLite WASM journals, Emscripten scratch files, other libraries - so mounting it whole means walking all of that on every scan. A named subdirectory is yours alone. Worth knowing: - Contents survive a reload and are private to the origin. The user can wipe them by clearing site data, and the browser may evict them under storage pressure - call `navigator.storage.persist()` to ask for protection. - Changes are detected from `lastModified` and `size`. OPFS writes are fast enough that a same-size rewrite inside a single millisecond can be missed, which only matters for writes coming from a worker or another tab - writes made through the hook update state directly. - The hook writes through `createWritable`, which needs Chrome 86+, Safari 26+ or Firefox 111+. Older Safari can read OPFS but only write it from a worker - `isOpfsWritable` is `false` there and `writeFile` rejects with a `NotSupportedError`. Watching still works; see [Working alongside a worker](#working-alongside-a-worker). - Quota is finite. A write that exceeds it rejects with `QuotaExceededError`, surfaced through `error` and `onError`. `navigator.storage.estimate()` reports the headroom. ### Working alongside a worker `createSyncAccessHandle()` is OPFS's fast path - synchronous, in-place reads and writes, and the only way SQLite WASM and similar get native-like speed. It is worker-only, so it is not something the hook uses. The two split the work: - **The worker owns the bytes.** It opens sync access handles and does the heavy I/O. - **The hook watches the results.** Mount the directory the worker writes into; changes appear on the next poll after the worker calls `flush()` or `close()` on its handle. ```ts // worker.ts const root = await navigator.storage.getDirectory(); const dir = await root.getDirectoryHandle("exports", { create: true }); const file = await dir.getFileHandle("report.json", { create: true }); const handle = await file.createSyncAccessHandle(); handle.truncate(0); handle.write(new TextEncoder().encode(JSON.stringify(report))); handle.flush(); handle.close(); ``` ```tsx // Component - sees exports/report.json within one poll of the worker closing it. const { files, addOpfsDirectory } = useFs(); useEffect(() => { addOpfsDirectory({ name: "exports" }); }, [addOpfsDirectory]); ``` Things to know: - **Keep databases out of the watched tree.** A database a worker writes to changes on nearly every poll. Mount a directory the database is not in, or opt into `binaryFilter` - `useFs({ filters: [...commonFilters, binaryFilter] })` - which skips `.sqlite`, `.db`, their `-wal` / `-shm` / `-journal` files, `.wasm` and other binary files. - **Sync access handles are exclusive.** While a worker holds one, `writeFile`, `deleteFile` and `deleteDirectory` on that file reject with an error named `NoModificationAllowedError` whose message names the path - check it with `isLockedError(error)` and retry after the worker closes the handle. - **A locked file keeps its last known contents.** If reading it fails during a scan, it is reported through `onError` once - not on every poll - and the error is reported again only if it fails again after a clean read. `getOpfsRoot()`, `isOpfsSupported()` and `isOpfsWritable()` are exported too, for reaching the handle directly - `(await getOpfsRoot({ name: "notes" })).removeEntry("todo.md")`. ## How watching works The hook re-scans every watched directory on an interval: 1. The tree is walked breadth-first with a bounded number of directories open at once. Filters that reject a directory prune the whole subtree, so `node_modules` is never enumerated. 2. Each discovered file is stat'd. Contents are only re-read when `lastModified` or `size` changed, so a steady-state poll over a large tree does no content I/O. 3. Added, changed and deleted files are diffed against the previous scan and reported through the callbacks. Rendered state updates are coalesced by `debounceInterval`; callbacks always fire immediately. Scans never throw. A directory that cannot be enumerated (revoked permission, removed mid-scan) or a file that cannot be read (locked by a worker) keeps its last known contents instead of being reported as deleted, and the error is surfaced through `error` and `onError` - once per file until it reads cleanly again. ## Permissions `onDirectorySelection` requests `mode: "read"` by default. Writing prompts for `readwrite` access the first time it is needed, which requires a user gesture - so call `writeFile` from an event handler. To get write access up front, pass `mode: "readwrite"`: ```tsx const fs = useFs({ mode: "readwrite" }); ``` OPFS needs none of this: an OPFS handle carries no permission at all, so `addOpfsDirectory` and every write against it work without a prompt or a gesture. `addDirectory` accepts a handle directly, which is how you restore access across sessions after persisting a handle in IndexedDB: ```tsx const handle = await loadHandleFromIndexedDb(); if ((await handle.queryPermission({ mode: "read" })) === "granted") { await fs.addDirectory(handle); } ``` The handle's own name is the path it is watched under. Pass `path` to override it, which a handle with no name - the OPFS root is the one that occurs in practice - requires: ```tsx await fs.addDirectory(handle, { path: "storage" }); ``` ## Filters A filter decides which entries are visible to the hook. `commonFilters` (the default) prunes build output, drops OS scratch files and honours every `.gitignore` in the tree. ```tsx import { commonFilters, createFilter, useFs } from "use-fs"; const onlyTypeScript = createFilter({ shouldIncludeFile: ({ name }) => name.endsWith(".ts") || name.endsWith(".tsx"), }); useFs({ filters: [...commonFilters, onlyTypeScript] }); ``` Every callback receives a context object rather than a bare path: | Field | Description | | --- | --- | | `path` | Full path including the watched root, e.g. `my-project/src/index.ts` | | `rootPath` | Path of the watched root, e.g. `my-project` | | `relativePath` | Path relative to the watched root, e.g. `src/index.ts` | | `name` | Name of the entry, e.g. `index.ts` | Filters may also implement `onDirectoryEnter`, which runs before any of a directory's entries are tested - that is how `gitFilter` loads a `.gitignore` before deciding anything in its directory. Built-in filters: - `distFilter` - prunes `node_modules`, `dist`, `build`, `out`, `vendor`, `coverage`, `.next`, `.nuxt`, `.turbo`, `.cache`, `.output`, `.svelte-kit`, `.parcel-cache` - `miscFilter` - drops `.DS_Store`, `Thumbs.db`, `desktop.ini` and `*.crswap` - `binaryFilter` - opt in, not part of `commonFilters`. Drops binary files by case-insensitive suffix (`DEFAULT_BINARY_FILE_SUFFIXES`): SQLite databases and their `-wal` / `-shm` / `-journal` files, `.wasm`, archives, images, audio, video, fonts and `.pdf`. Worth adding when a worker writes a database into a watched directory - otherwise each write means re-reading the whole file as text - `gitFilter` - honours nested `.gitignore` files and always skips `.git` - `commonFilters` - `distFilter`, `miscFilter` and `gitFilter` Builders: `createFilter`, `createExcludedDirectoryFilter`, `createExcludedFileFilter`. ### Options | Option | Default | Description | | --- | --- | --- | | `filters` | `commonFilters` | Filters applied while walking watched directories | | `onFilesAdded` | – | Called with files seen for the first time | | `onFilesChanged` | – | Called with files whose contents changed | | `onFilesDeleted` | – | Called with files that disappeared, mapped to their last known contents | | `onError` | – | Called for every recoverable error | | `pollInterval` | `300` | Delay between scans, in milliseconds | | `debounceInterval` | `50` | How long rendered state updates are coalesced. `0` updates synchronously | | `batchSize` | `50` | Maximum number of files read concurrently | | `concurrency` | `8` | Maximum number of directories enumerated concurrently | | `mode` | `"read"` | Access level requested when opening the picker | | `autoStartPolling` | `true` | Start polling as soon as a directory is added | | `processingIndicatorDelay` | `100` | How long a scan must run before `isProcessing` flips to `true` | Options are read at the moment they are used, so inline callbacks and filter arrays never need to be memoized. ### Return values | Value | Description | | --- | --- | | `files: Map` | Watched files, keyed by path | | `handles: Map` | File handles, keyed by path | | `directories: string[]` | Paths of the watched root directories | | `isProcessing: boolean` | Whether a scan has been running long enough to be worth showing | | `isPolling: boolean` | Whether the hook is polling for changes | | `isBrowserSupported: boolean` | Whether the directory picker is available | | `isOpfsSupported: boolean` | Whether the origin private file system is available | | `isOpfsWritable: boolean` | Whether `writeFile` / `createFile` can write to OPFS (`false` in Safari before 26) | | `error: Error \| null` | The most recent recoverable error | | `onDirectorySelection(options?)` | Opens the picker and watches the chosen directory. Resolves to its path, or `null` if the picker was dismissed | | `addDirectory(handle, options?)` | Watches an existing handle. Resolves to the path it is exposed under | | `addOpfsDirectory(options?)` | Watches the origin private file system. Resolves to the path it is exposed under | | `removeDirectory(path)` | Stops watching a directory without touching disk | | `onClear()` | Stops watching everything and resets the hook | | `refresh()` | Runs a scan immediately | | `startPolling()` / `stopPolling()` | Manual control over the polling loop | | `writeFile(path, data, options?)` | Writes to a file, creating it and any missing parents by default | | `createFile(path, initialData?)` | Creates (or opens) a file and returns its handle | | `deleteFile(path)` | Deletes a file | | `deleteDirectory(path)` | Deletes a directory and everything below it | | `requestPermission(mode?)` | Requests access for every watched directory | `writeFile` replaces the file's contents by default. Pass `{ truncate: false }` to write over the existing bytes from offset `0` and keep anything trailing, or `{ create: false }` to fail instead of creating a missing file. ### Lower-level exports `walkDirectory`, `scanDirectories`, `toContentMap`, `normalizePath`, `isFileSystemAccessSupported`, `isOpfsSupported`, `isOpfsWritable`, `isLockedError`, `getDirectoryPicker`, `getOpfsRoot` and `ensurePermission` are exported for building on top of the same primitives the hook uses.