File Storage with useFS Hook in Functional Web Components

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:
- Functional Web Components
- Functional Todo App
- Async State Management
- Bottom-up Browser Storage
- 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 modeencrypt: boolean
- Enable encryptionencryptionPassword: string
- Encryption password
Core Methods
selectDirectory()
- Open directory picker (FSA only)listDirectory(path?)
- List files/directoriesreadFile(fileName, path?)
- Read file contentwriteFile(fileName, content, path?)
- Write fileremoveFile(fileName, path?)
- Delete fileremoveDirectory(dirName, path?)
- Delete directory recursivelyuploadFile(file, targetName?, path?)
- Upload File objectuploadFiles(files, path?)
- Upload multiple files
State Properties
isReady: boolean
- Hook initialized and readyfsMode: 'fsa' | 'opfs'
- Current storage modedirectoryPath: string
- Current directory display namehasPermission: boolean
- FSA permission grantedhasDirectoryAccess: boolean
- Directory selected/availableencryptionEnabled: boolean
- Encryption activeencryptionReady: boolean | null
- Encryption initializederror: Error | null
- Last error
Mode Switching
switchToOPFS()
- Switch to OPFS modeswitchToFSA()
- 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.