# use-fs: guide for coding agents

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<string, string>` 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.

Full docs: https://use-fs.com/llms.txt

## Install

```sh
npm install use-fs
```

## Minimal example (works in every browser)

```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 <opfs>/notes, creating it if it does not exist.
      addOpfsDirectory({ name: "notes" });
    }
  }, [isOpfsSupported, addOpfsDirectory]);

  return (
    <button onClick={() => writeFile("notes/todo.md", "buy milk")}>
      Save ({files.size} files)
    </button>
  );
}
```

## Folder on disk, with OPFS as the fallback

```tsx
const {
  isBrowserSupported,
  isOpfsSupported,
  onDirectorySelection,
  addOpfsDirectory,
  directories,
} = useFs();

<button onClick={() => onDirectorySelection()} disabled={!isBrowserSupported}>
  Open a folder
</button>
<button onClick={() => addOpfsDirectory({ name: "workspace" })} disabled={!isOpfsSupported}>
  Use browser storage
</button>

// Both roots land here, and `files` spans them.
<p>Watching: {directories.join(", ") || "nothing yet"}</p>
```

## Rules that are easy to get wrong

1. **Client only.** It is a React hook over browser APIs. In the Next.js App Router, put it in a `"use client"` component.
2. **Check support first.** `isBrowserSupported` gates the directory picker (desktop Chrome, Edge, Opera). `isOpfsSupported` gates OPFS reads and `isOpfsWritable` gates writes to it (Safari before 26 can read but not write from the main thread).
3. **The picker needs a user gesture.** Call `onDirectorySelection()` from a click handler, never from an effect. The first `writeFile` to a picked folder prompts for `readwrite` too, unless you passed `mode: "readwrite"`. `addOpfsDirectory()` needs no gesture and no prompt.
4. **Paths include the watched root.** Keys of `files` and every path you pass to `writeFile` / `createFile` / `deleteFile` / `deleteDirectory` look like `notes/todo.md`, not `todo.md`. They are POSIX-style; `.` and `..` are rejected, and a path outside every watched directory throws.
5. **Mount OPFS by `name`, and keep binaries out.** `addOpfsDirectory({ name: "notes" })` scopes the scan to your own subdirectory. Contents are read as UTF-8 text, so add `binaryFilter` (`filters: [...commonFilters, binaryFilter]`) when databases, images or archives live in the tree.

## 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<string, string>` | Watched files, keyed by path |
| `handles: Map<string, FileSystemFileHandle>` | 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.

## 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`.
