Skip to main content

File Storage with useFS Hook in Functional Web Components

· 12 min read
xoron
positive-intentions

Building on the functional web components framework we've been developing in this series, today we're tackling one of the most powerful capabilities modern browsers offer: file system access. The useFS hook brings unified file storage to dim, bridging the File System Access API and Origin Private File System (OPFS) with optional encryption—all wrapped in a clean, functional interface.

Modern web applications increasingly need to work with files: text editors that save to your local disk, note-taking apps that persist data, image editors that let you modify real files. But browser file APIs are fragmented and complex. The useFS hook solves this by providing a single, intuitive interface that works everywhere, automatically falling back from File System Access to OPFS, with built-in encryption for sensitive data.


The Dim series:

  1. Functional Web Components
  2. Functional Todo App
  3. Async State Management
  4. Bottom-up Browser Storage
  5. File Storage with useFS Hook

The File Storage Problem

Web developers have long struggled with file persistence. We've had IndexedDB for structured data, LocalStorage for small key-value pairs, but working with actual files—especially user-chosen files—remained clunky. Modern browsers now offer two powerful but different solutions:

File System Access API lets you work directly with the user's file system. Pick a folder, read files, write changes—they appear immediately in File Explorer or Finder. Perfect for code editors, document tools, or any app working with existing files.

Origin Private File System (OPFS) gives you a private, isolated storage area. No permission prompts, faster operations, but files are sandboxed to your origin. Great for app-specific data, cached assets, or when you don't need user access.

The problem? They have different APIs, browser support varies, and handling the fallback logic is tedious. That's where useFS comes in.


Understanding the useFS Hook

The useFS hook provides a unified interface across both APIs. It tries File System Access first, falls back to OPFS if unavailable, and exposes the same methods regardless of which backend it's using. Here's the simplest possible example:

import { define, html } from '../core/dim.ts';

const FileDemo = (props, { useFS, useState, html }) => {
const fs = useFS();
const [files, setFiles] = useState([]);

const loadFiles = async () => {
const entries = await fs.listDirectory();
setFiles(entries);
};

return html`
<div>
${fs.isReady ? html`
<button @click="${loadFiles}">Load Files</button>
<ul>
${files.map(f => html`<li>${f.name}</li>`)}
</ul>
` : html`
<button @click="${() => fs.selectDirectory()}">
Select Directory
</button>
`}
</div>
`;
};

define({ tag: 'file-demo', component: FileDemo });

Notice how the code doesn't care whether it's using FSA or OPFS. The hook abstracts that complexity. In FSA mode, selectDirectory() prompts the user. In OPFS mode, it uses the built-in private storage. Same code, different backends.


The Architecture: How It Works

Unlike React hooks that magically access component context, dim hooks require explicit dependency passing. This keeps things transparent:

function useFS(options = {}, hooks) {
const { useState, useEffect, useStore } = hooks;
// Hook implementation
}

The hook itself is called from your component with the hooks object passed as the second parameter:

const MyComponent = (props, { useFS, useState, useEffect }) => {
const fs = useFS(); // useFS internally receives the hooks
// Component code
};

This explicit pattern means:

  • No hidden dependencies - You see exactly what each hook needs
  • Easier testing - Pass mock hooks without fighting framework magic
  • Better composition - Hooks can use other hooks explicitly

The useFS hook internally manages mode detection, permission state, and storage handles using useState, useEffect, and useStore for persistence across sessions.


Basic Operations: The Essential API

Let's walk through the core file operations you'll use most often.

Reading Files

const handleReadFile = async (fileName) => {
try {
const content = await fs.readFile(fileName);

// Text files return strings
if (typeof content === 'string') {
console.log('File content:', content);
}

// Binary files return metadata objects
if (content.type === 'binary') {
console.log('Binary file:', content.mimeType, content.size);
// content.data is base64-encoded
}
} catch (err) {
console.error('Read failed:', err);
}
};

The hook automatically detects file types. Text files (.txt, .md, .json, .js) are read as strings. Everything else is treated as binary and returned with metadata including base64-encoded data, MIME type, and size.

Writing Files

const handleCreateFile = async () => {
try {
await fs.writeFile('notes.txt', 'Hello, World!');
console.log('File created!');
} catch (err) {
console.error('Write failed:', err);
}
};

In FSA mode, this checks for write permissions first. In OPFS mode, it just writes. The hook handles those differences internally.

Listing Directories

const loadFiles = async () => {
const entries = await fs.listDirectory();
// Returns [{ name: 'file.txt', kind: 'file', handle }, ...]

const files = entries.filter(e => e.kind === 'file');
const dirs = entries.filter(e => e.kind === 'directory');
};

Entries are automatically sorted: directories first, then files, both alphabetically.

Nested Paths

The hook supports subdirectories with path parameters:

// Create file in nested directory (creates dirs if needed)
await fs.writeFile('config.json', data, 'app/settings');

// Read from subdirectory
const config = await fs.readFile('config.json', 'app/settings');

// List subdirectory
const entries = await fs.listDirectory('app/settings');

File Uploads: Handling User Files

Beyond creating files from strings, you often need to let users upload files from their device. The hook includes specialized methods for this:

Single File Upload

const handleUpload = async (event) => {
const file = event.target.files[0];

const result = await fs.uploadFile(file);
console.log(`Uploaded ${result.fileName} (${result.size} bytes)`);
};

// In template
html`<input type="file" @change="${handleUpload}" />`;

The uploadFile method reads the file, detects if it's text or binary, and writes it appropriately. Text files are stored as-is. Binary files (images, PDFs, etc.) are base64-encoded with metadata.

Batch Upload

const handleBatchUpload = async (event) => {
const files = Array.from(event.target.files);
const results = await fs.uploadFiles(files);

results.forEach(r => {
if (r.status === 'success') {
console.log('✓', r.fileName);
} else {
console.error('✗', r.fileName, r.error);
}
});
};

html`<input type="file" multiple @change="${handleBatchUpload}" />`;

The hook processes each file individually and returns detailed results for each, including error messages if any fail.

Drag and Drop

const handleDrop = async (event) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
await fs.uploadFiles(files);
};

html`
<div
@drop="${handleDrop}"
@dragover="${(e) => e.preventDefault()}"
>
Drop files here
</div>
`;

Encryption: Securing Your Files

One of the most powerful features of useFS is built-in encryption. Enable it with a single option and every file operation becomes encrypted automatically:

const fs = useFS({
encrypt: true,
encryptionPassword: 'my-secure-password-123'
});

Now all writes are encrypted before storage, all reads are decrypted after retrieval:

// These work identically whether encryption is on or off
await fs.writeFile('secrets.txt', 'Confidential data');
const data = await fs.readFile('secrets.txt');
// data === 'Confidential data' (decrypted automatically)

The encryption is transparent. Your application code doesn't change. The hook handles encryption/decryption behind the scenes using AES-GCM with the password you provide.

Encryption with OPFS

Encryption is particularly valuable with OPFS for storing sensitive application data:

const fs = useFS({
opfs: true, // Force OPFS mode
encrypt: true, // Enable encryption
encryptionPassword: 'vault-2024'
});

// Store encrypted credentials in OPFS
await fs.writeFile('auth.json', JSON.stringify({
apiKey: 'sk-123456',
token: 'secret-token'
}));

The files live in your origin's private storage, encrypted with your password. Even if someone gains access to the browser's storage, they can't read the files without the password.

Checking Encryption Status

if (!fs.encryptionReady) {
return html`<p>Initializing encryption...</p>`;
}

return html`
<p>🔐 Encryption active with ${fs.fsMode}</p>
`;

The encryptionReady property tells you when the crypto manager is initialized and ready to use.


Mode Switching: FSA vs OPFS

The hook automatically chooses File System Access if available, falling back to OPFS if not. But you can control this:

Force OPFS Mode

const fs = useFS({ opfs: true });
// Always uses OPFS, never prompts for directory

Switch Modes Programmatically

const [mode, setMode] = useState('auto');

const fs = useFS({ opfs: mode === 'opfs' });

const switchToOPFS = () => {
setMode('opfs');
// Hook will reinitialize with OPFS
};

const switchToFSA = () => {
setMode('auto');
// Hook will use FSA if supported
};

Check Current Mode

if (fs.fsMode === 'opfs') {
return html`<p>Using private storage</p>`;
} else {
return html`<p>Using directory: ${fs.directoryPath}</p>`;
}

This is useful for showing mode-specific UI, like hiding the "Select Directory" button in OPFS mode.


Complete Example: A File Manager

Let's put it all together with a fully functional file manager component:

import { define, html, css } from '../core/dim.ts';

const FileManager = (props, { useFS, useState, useEffect, useStyle, html, css }) => {
const fs = useFS();
const [files, setFiles] = useState([]);
const [currentPath, setCurrentPath] = useState('');
const [selectedFile, setSelectedFile] = useState('');
const [content, setContent] = useState('');
const [newName, setNewName] = useState('');
const [newContent, setNewContent] = useState('');

// Auto-load files when path or fs.isReady changes
useEffect(() => {
if (!fs.isReady) return;

const load = async () => {
try {
const entries = await fs.listDirectory(currentPath);
setFiles(entries);
} catch (err) {
console.error('Load failed:', err);
}
};

load();
}, [fs.isReady, currentPath]);

useStyle(css`
.file-manager {
padding: 20px;
font-family: system-ui;
}
.file-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #eee;
}
.file-item:hover {
background: #f5f5f5;
}
`);

const handleSelect = async (file) => {
if (file.kind === 'directory') {
setCurrentPath(currentPath ? `${currentPath}/${file.name}` : file.name);
} else {
const data = await fs.readFile(file.name, currentPath);
setSelectedFile(file.name);
setContent(typeof data === 'string' ? data : 'Binary file');
}
};

const handleCreate = async () => {
if (!newName) return;
await fs.writeFile(newName, newContent, currentPath);
setNewName('');
setNewContent('');
const entries = await fs.listDirectory(currentPath);
setFiles(entries);
};

const navigateUp = () => {
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
setCurrentPath(parts.join('/'));
};

return html`
<div class="file-manager">
<h2>File Manager</h2>

${!fs.isReady ? html`
<button @click="${() => fs.selectDirectory()}">
Select Directory
</button>
` : html`
<div>
<p>Path: ${currentPath || '/'} (${fs.fsMode})</p>

${currentPath ? html`
<button @click="${navigateUp}">⬆ Up</button>
` : ''}

<div>
${files.map(f => html`
<div class="file-item" @click="${() => handleSelect(f)}">
${f.kind === 'directory' ? '📁' : '📄'} ${f.name}
</div>
`)}
</div>

<h3>Create File</h3>
<input
placeholder="Filename"
.value="${newName}"
@input="${(e) => setNewName(e.target.value)}"
/>
<textarea
placeholder="Content"
.value="${newContent}"
@input="${(e) => setNewContent(e.target.value)}"
></textarea>
<button @click="${handleCreate}">Create</button>

${selectedFile ? html`
<h3>${selectedFile}</h3>
<pre>${content}</pre>
` : ''}
</div>
`}
</div>
`;
};

define({ tag: 'file-manager', component: FileManager });

This complete component handles:

  • Directory selection (FSA) or automatic OPFS initialization
  • File/directory navigation with breadcrumb support
  • File reading with preview
  • File creation with validation
  • Responsive UI that adapts to current state

Real-World Use Cases

Encrypted Note-Taking App

const fs = useFS({
opfs: true,
encrypt: true,
encryptionPassword: userPassword
});

// All notes encrypted in OPFS
await fs.writeFile(`notes/${noteId}.txt`, noteContent);

Perfect for private notes, journal apps, or any text storage where privacy matters.

Local-First Code Editor

const fs = useFS(); // FSA mode

// User selects their project folder
await fs.selectDirectory();

// Read/write project files directly
const code = await fs.readFile('src/index.js');
await fs.writeFile('src/index.js', updatedCode);

Works like VS Code or Sublime: edit real files on the user's disk, changes appear immediately in other apps.

Offline-First PWA

const fs = useFS({ opfs: true });

// Cache application data
await fs.writeFile('cache/data.json', cachedData);
await fs.writeFile('cache/images/photo.jpg', imageBlob);

// Retrieve when offline
const data = await fs.readFile('cache/data.json');

OPFS provides reliable, fast offline storage that persists across sessions.

CSV/JSON Import/Export Tool

// User uploads CSV
const handleImport = async (file) => {
await fs.uploadFile(file);
const csv = await fs.readFile(file.name);
const data = parseCSV(csv);
processData(data);
};

// Export processed data
const handleExport = async () => {
const csv = generateCSV(processedData);
await fs.writeFile('export.csv', csv);
// User can access file through FSA
};

Best Practices

1. Always Check isReady

if (!fs.isReady) {
return html`<p>Loading file system...</p>`;
}

The hook needs time to initialize. Don't call methods until isReady is true.

2. Handle Errors Gracefully

try {
await fs.writeFile(name, content);
} catch (err) {
showUserError(`Failed to save: ${err.message}`);
}

File operations can fail for many reasons: permissions, disk space, invalid paths. Always wrap in try/catch.

3. Organize with Paths

await fs.writeFile('user.json', userData, 'app-data/users');
await fs.writeFile('theme.json', themeData, 'app-data/settings');

Use subdirectories to organize files logically, just like on disk.

4. Provide Mode-Specific UI

${fs.fsMode === 'fsa' && !fs.hasDirectoryAccess ? html`
<button @click="${() => fs.selectDirectory()}">
Select Directory
</button>
` : ''}

Show the directory selector only when using FSA and no directory is selected.

5. Consider Encryption Carefully

// ✓ Good: Encrypt sensitive data
const fs = useFS({ encrypt: true, encryptionPassword: userKey });

// ✗ Bad: Hardcoded password in code
const fs = useFS({ encrypt: true, encryptionPassword: 'secret123' });

Encryption is only as strong as the password. Let users provide it, don't hardcode.


API Reference Summary

Initialization

const fs = useFS(options, hooks);

Options:

  • opfs: boolean - Force OPFS mode
  • encrypt: boolean - Enable encryption
  • encryptionPassword: string - Encryption password

Core Methods

  • selectDirectory() - Open directory picker (FSA only)
  • listDirectory(path?) - List files/directories
  • readFile(fileName, path?) - Read file content
  • writeFile(fileName, content, path?) - Write file
  • removeFile(fileName, path?) - Delete file
  • removeDirectory(dirName, path?) - Delete directory recursively
  • uploadFile(file, targetName?, path?) - Upload File object
  • uploadFiles(files, path?) - Upload multiple files

State Properties

  • isReady: boolean - Hook initialized and ready
  • fsMode: 'fsa' | 'opfs' - Current storage mode
  • directoryPath: string - Current directory display name
  • hasPermission: boolean - FSA permission granted
  • hasDirectoryAccess: boolean - Directory selected/available
  • encryptionEnabled: boolean - Encryption active
  • encryptionReady: boolean | null - Encryption initialized
  • error: Error | null - Last error

Mode Switching

  • switchToOPFS() - Switch to OPFS mode
  • switchToFSA() - Switch to FSA mode

What We've Built

The useFS hook demonstrates how functional web components can wrap complex browser APIs into simple, composable interfaces. We've covered:

  • Unified file storage across File System Access API and OPFS
  • Transparent encryption with AES-GCM for sensitive data
  • Smart fallbacks that work everywhere
  • Binary file handling for images, documents, any file type
  • Path-based organization with nested directories
  • Upload utilities for user files with drag-and-drop support

This approach to file storage enables building sophisticated web applications—code editors, note-taking apps, offline-first PWAs—while maintaining the functional, declarative style that makes dim components easy to reason about and compose.

The hook fits naturally into dim's architecture: explicit dependencies, no magic, just functions and data. It's the browser file system, but functional.


Next Steps

With file storage solved, we've built a complete foundation for functional web components:

✅ Custom hooks (useState, useEffect, useMemo) ✅ Component scoping and styling ✅ Async state management ✅ Bottom-up storage patterns ✅ File system access with encryption

The dim framework is now capable of building real, production-ready applications. From here, you can explore:

  • Syncing file systems across devices
  • Collaborative editing with CRDTs
  • Version control for file changes
  • Media handling with advanced file type support
  • Performance optimization with lazy loading and caching

Try it yourself: Check out the complete useFS implementation and interactive demos in the dim repository.

The future of web components is functional. The future of file storage is unified. Welcome to dim.