File Storage with useFS Hook
Building on the functional web components framework we created in previous guides, this article explores how to add file storage capabilities to your dim-based applications. We'll introduce the useFS
hook, which provides a unified interface for file system operations using modern web APIs.
The useFS
hook bridges two powerful browser file storage technologies: the File System Access API for accessing user-selected directories, and the Origin Private File System (OPFS) as a fallback. This dual approach ensures your application can persist files regardless of browser support, with optional end-to-end encryption for sensitive data.
Understanding Browser File Storage
Modern browsers offer two distinct approaches to file storage:
File System Access API
The File System Access API allows web applications to read and write files on the user's local file system with explicit user permission. This API is ideal for applications that need to work with existing files or save directly to user-chosen locations.
Key Features:
- Direct access to user's file system
- Requires explicit user permission
- Files persist outside the browser
- Changes are immediately visible in file explorers
Browser Support:
- Chrome/Edge 86+
- Opera 72+
- Safari 15.2+ (partial support)
Origin Private File System (OPFS)
OPFS provides a private storage area isolated to your application's origin. It's similar to IndexedDB but optimized for file operations and doesn't require user permission.
Key Features:
- No permission prompts needed
- Isolated from user's file system
- Fast read/write operations
- Persistent across sessions
Browser Support:
- Chrome/Edge 102+
- Firefox 111+
- Safari 15.2+
The useFS Hook Architecture
The useFS
hook abstracts the complexity of working with these APIs by providing a consistent interface that works with both. Here's how it integrates into the dim framework:
import { useFS } from '../hooks/useFS';
function MyComponent(props, { useFS, useState, html }) {
const fs = useFS();
const [files, setFiles] = useState([]);
// The hook provides a unified API regardless of the underlying storage
return html`
<div>
<button @click="${() => fs.selectDirectory()}">
Select Directory
</button>
</div>
`;
}
Hook Dependencies
Unlike React, where hooks have implicit access to component context, dim components pass hooks as the second parameter. The useFS
hook requires these dependencies:
function useFS(options = {}, hooks) {
const { useState, useEffect, useStore } = hooks;
// Hook implementation uses these to manage internal state
}
This explicit dependency pattern ensures:
- No hidden coupling to framework internals
- Clear understanding of hook requirements
- Easier testing and composition
Basic File Operations
Let's walk through implementing core file operations in a dim component.
Initializing the Hook
function FileManager(props, { useFS, useState, html, css }) {
// Initialize with default settings (tries FSA, falls back to OPFS)
const fs = useFS();
const [files, setFiles] = useState([]);
const [content, setContent] = useState('');
return html`
<div>
${!fs.isReady ? html`
<p>Initializing file system...</p>
` : html`
<p>Ready! Using ${fs.fsMode}</p>
`}
</div>
`;
}
Directory Selection (FSA Mode)
When using File System Access API, users must select a directory:
const handleSelectDirectory = async () => {
try {
const success = await fs.selectDirectory();
if (success) {
// Directory selected, ready to read/write files
const entries = await fs.listDirectory();
setFiles(entries);
}
} catch (err) {
console.error('Failed to select directory:', err);
}
};
Listing Files
const loadFiles = async (path = '') => {
try {
const entries = await fs.listDirectory(path);
// entries is an array of { name, kind, handle }
// kind is either 'file' or 'directory'
setFiles(entries);
} catch (err) {
console.error('Failed to list files:', err);
}
};
Reading Files
The readFile
method handles both text and binary files:
const handleReadFile = async (fileName) => {
try {
const content = await fs.readFile(fileName);
// Check if it's a binary file
if (typeof content === 'object' && content.type === 'binary') {
console.log('Binary file:', content.mimeType);
// content.data contains base64-encoded data
// content.size is the approximate file size
} else {
// Text content
setContent(content);
}
} catch (err) {
console.error('Failed to read file:', err);
}
};
Writing Files
const handleCreateFile = async (fileName, fileContent) => {
try {
await fs.writeFile(fileName, fileContent);
console.log('File created successfully');
// Refresh file list
await loadFiles();
} catch (err) {
console.error('Failed to create file:', err);
}
};
Working with Subdirectories
The useFS
hook supports nested directory structures:
// Create a file in a subdirectory (creates directory if needed)
await fs.writeFile('example.txt', 'Hello', 'my-folder/subfolder');
// Read from subdirectory
const content = await fs.readFile('example.txt', 'my-folder/subfolder');
// List subdirectory contents
const entries = await fs.listDirectory('my-folder/subfolder');
File Upload and Binary Files
The useFS
hook includes specialized methods for handling file uploads from the user's device.
Single File Upload
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const result = await fs.uploadFile(file);
console.log('Uploaded:', result.fileName, result.size, 'bytes');
// Refresh file list
await loadFiles();
} catch (err) {
console.error('Upload failed:', err);
}
};
// In your template
html`
<input
type="file"
@change="${handleFileUpload}"
/>
`;
Multiple File Upload
const handleMultipleUpload = async (event) => {
const files = Array.from(event.target.files);
try {
const results = await fs.uploadFiles(files);
// Results is an array of { fileName, status, size, type }
results.forEach(result => {
if (result.status === 'success') {
console.log('✓', result.fileName);
} else {
console.error('✗', result.fileName, result.error);
}
});
await loadFiles();
} catch (err) {
console.error('Batch upload failed:', err);
}
};
html`
<input
type="file"
multiple
@change="${handleMultipleUpload}"
/>
`;
Drag and Drop Upload
const handleDrop = async (event) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const results = await fs.uploadFiles(files);
await loadFiles();
};
const handleDragOver = (event) => {
event.preventDefault();
};
html`
<div
@drop="${handleDrop}"
@dragover="${handleDragOver}"
style="border: 2px dashed #ccc; padding: 20px;"
>
Drop files here
</div>
`;
Encryption Support
The useFS
hook includes built-in AES-GCM encryption for securing file contents.
Enabling Encryption
function SecureFileManager(props, { useFS, html }) {
// Enable encryption with custom password
const fs = useFS({
encrypt: true,
encryptionPassword: 'my-secure-password-123'
});
// Wait for encryption to be ready
if (!fs.encryptionReady) {
return html`<p>Initializing encryption...</p>`;
}
return html`
<div>
<p>🔐 Encryption enabled</p>
</div>
`;
}
How Encryption Works
When encryption is enabled:
- Write Operation: Content is automatically encrypted before being written to storage
- Read Operation: Encrypted content is automatically decrypted when read
- Transparent: Your code works the same way, encryption happens behind the scenes
// These operations work identically whether encryption is on or off
await fs.writeFile('secrets.txt', 'My secret data');
const content = await fs.readFile('secrets.txt');
// content === 'My secret data'
Encryption with OPFS
Encryption is particularly useful with OPFS for storing sensitive data:
const fs = useFS({
opfs: true, // Force OPFS mode
encrypt: true, // Enable encryption
encryptionPassword: 'vault-password-456'
});
// All files are encrypted in OPFS storage
await fs.writeFile('credentials.json', JSON.stringify({
apiKey: 'sk-1234567890',
token: 'secret-token'
}));
Password Management
You can change passwords dynamically by reinitializing the hook:
const [password, setPassword] = useState('default-password');
const fs = useFS({
encrypt: true,
encryptionPassword: password
});
// When password changes, the hook reinitializes
const handlePasswordChange = (newPassword) => {
setPassword(newPassword);
// Note: Files encrypted with old password won't be readable
};
Mode Switching and Fallbacks
The useFS
hook provides methods to switch between storage modes.
Forcing OPFS Mode
// Force OPFS from the start
const fs = useFS({ opfs: true });
// Or switch to OPFS programmatically
fs.switchToOPFS();
Switching to FSA Mode
try {
fs.switchToFSA();
// User will need to select a directory
await fs.selectDirectory();
} catch (err) {
console.error('FSA not supported:', err);
}
Checking Current Mode
const fs = useFS();
// Check which mode is active
if (fs.fsMode === 'opfs') {
console.log('Using Origin Private File System');
} else if (fs.fsMode === 'fsa') {
console.log('Using File System Access API');
}
Complete Example: File Manager Component
Here's a complete file manager implementation using the useFS
hook:
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 [fileContent, setFileContent] = useState('');
const [newFileName, setNewFileName] = useState('');
const [newFileContent, setNewFileContent] = useState('');
// Load files when directory changes
useEffect(() => {
if (!fs.isReady) return;
const loadFiles = async () => {
try {
const entries = await fs.listDirectory(currentPath);
setFiles(entries);
} catch (err) {
console.error('Failed to load files:', err);
}
};
loadFiles();
}, [fs.isReady, currentPath]);
// Component styles
useStyle(css`
.file-manager {
padding: 20px;
font-family: system-ui;
}
.file-list {
border: 1px solid #ddd;
border-radius: 4px;
margin: 10px 0;
}
.file-item {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
}
.file-item:hover {
background: #f5f5f5;
}
`);
const handleSelectDirectory = async () => {
const success = await fs.selectDirectory();
if (success) {
const entries = await fs.listDirectory();
setFiles(entries);
}
};
const handleReadFile = async (fileName) => {
try {
const content = await fs.readFile(fileName, currentPath);
setSelectedFile(fileName);
setFileContent(content);
} catch (err) {
console.error('Read failed:', err);
}
};
const handleCreateFile = async () => {
if (!newFileName) return;
try {
await fs.writeFile(newFileName, newFileContent, currentPath);
setNewFileName('');
setNewFileContent('');
// Refresh file list
const entries = await fs.listDirectory(currentPath);
setFiles(entries);
} catch (err) {
console.error('Create failed:', err);
}
};
const handleNavigate = (dirName) => {
setCurrentPath(currentPath ? `${currentPath}/${dirName}` : dirName);
};
const handleNavigateUp = () => {
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
setCurrentPath(parts.join('/'));
};
return html`
<div class="file-manager">
<h2>File Manager</h2>
${!fs.isReady ? html`
<div>
<p>Please select a directory to get started</p>
<button @click="${handleSelectDirectory}">
Select Directory
</button>
</div>
` : html`
<div>
<p>Current path: ${currentPath || '/'}</p>
<p>Mode: ${fs.fsMode === 'opfs' ? 'OPFS' : 'File System Access'}</p>
${currentPath ? html`
<button @click="${handleNavigateUp}">⬆ Up</button>
` : ''}
<div class="file-list">
${files.map(file => html`
<div
class="file-item"
@click="${() => {
if (file.kind === 'directory') {
handleNavigate(file.name);
} else {
handleReadFile(file.name);
}
}}"
>
${file.kind === 'directory' ? '📁' : '📄'} ${file.name}
</div>
`)}
${files.length === 0 ? html`
<div class="file-item">No files found</div>
` : ''}
</div>
<div>
<h3>Create New File</h3>
<input
type="text"
placeholder="File name"
.value="${newFileName}"
@input="${(e) => setNewFileName(e.target.value)}"
/>
<textarea
placeholder="Content"
.value="${newFileContent}"
@input="${(e) => setNewFileContent(e.target.value)}"
></textarea>
<button @click="${handleCreateFile}">Create</button>
</div>
${selectedFile ? html`
<div>
<h3>File: ${selectedFile}</h3>
<pre>${fileContent}</pre>
</div>
` : ''}
</div>
`}
</div>
`;
};
define({ tag: 'file-manager', component: FileManager });
API Reference
Hook Initialization
const fs = useFS(options, hooks);
Options:
opfs
(boolean): Force OPFS mode instead of FSA. Default:false
encrypt
(boolean): Enable file encryption. Default:false
encryptionPassword
(string): Password for encryption. Default:'useFS-default-password'
Hooks: Object containing { useState, useEffect, useStore }
Core Methods
selectDirectory()
Opens directory picker (FSA mode only). Returns Promise<boolean>
.
listDirectory(path?)
Lists files and directories. Returns Promise<Array<{name, kind, handle}>>
.
readFile(fileName, path?)
Reads file content. Returns Promise<string | BinaryFileObject>
.
writeFile(fileName, content, path?)
Writes content to file. Returns Promise<void>
.
removeFile(fileName, path?)
Deletes a file. Returns Promise<void>
.
removeDirectory(dirName, path?)
Recursively deletes a directory. Returns Promise<void>
.
uploadFile(file, targetFileName?, path?)
Uploads a File object. Returns Promise<{success, fileName, size, type}>
.
uploadFiles(files, path?)
Uploads multiple File objects. Returns Promise<Array<{fileName, status, size, type, error?}>>
.
State Properties
isReady
(boolean): Whether the hook is initialized and readyfsMode
('fsa' | 'opfs'): Current storage modedirectoryPath
(string): Display name of current directoryhasPermission
(boolean): Whether FSA permission is grantedhasDirectoryAccess
(boolean): Whether a directory is selected/availableerror
(Error | null): Last error that occurredencryptionEnabled
(boolean): Whether encryption is activeencryptionReady
(boolean | null): Whether encryption is initialized
Mode Switching
switchToOPFS()
: Switch to OPFS modeswitchToFSA()
: Switch to FSA mode (throws if not supported)
Best Practices
1. Always Check isReady
if (!fs.isReady) {
return html`<p>Loading...</p>`;
}
2. Handle Errors Gracefully
try {
await fs.writeFile('test.txt', 'content');
} catch (err) {
// Show user-friendly error message
console.error('Operation failed:', err.message);
}
3. Use Path Parameters for Organization
// Organize files in subdirectories
await fs.writeFile('config.json', data, 'app-data/settings');
await fs.writeFile('user.json', userData, 'app-data/users');
4. Provide Mode-Specific UI
if (fs.fsMode === 'fsa' && !fs.hasDirectoryAccess) {
return html`
<button @click="${() => fs.selectDirectory()}">
Select Directory
</button>
`;
}
5. Clean Up State on Mode Switch
const handleSwitchMode = () => {
setFiles([]);
setSelectedFile('');
fs.switchToOPFS();
};
Use Cases
Note-Taking App
const fs = useFS({ opfs: true, encrypt: true });
// Notes stored in encrypted OPFS, private to your app
Code Editor
const fs = useFS(); // FSA mode for working with real project files
// Users can select project folder and edit files directly
Data Import/Export Tool
const fs = useFS();
// Read CSV/JSON from user's system, process, write back
Offline-First PWA
const fs = useFS({ opfs: true });
// Use OPFS for reliable offline storage
Conclusion
The useFS
hook brings powerful file storage capabilities to dim's functional web components framework. By abstracting the complexity of File System Access API and OPFS, it provides a simple, consistent interface for file operations with optional encryption.
Key takeaways:
- Dual Mode Support: Automatically falls back from FSA to OPFS
- Transparent Encryption: Enable with a single option
- Path-Based Organization: Support for nested directories
- Binary File Handling: Upload and store any file type
- Functional Pattern: Fits naturally into dim's hook-based architecture
This approach to file storage enables building sophisticated web applications that can work with user files while maintaining the functional, declarative style of modern web development.
For a complete working example, check out the useFS story files in the dim repository.