Skip to main content

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:

  1. Write Operation: Content is automatically encrypted before being written to storage
  2. Read Operation: Encrypted content is automatically decrypted when read
  3. 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 ready
  • fsMode ('fsa' | 'opfs'): Current storage mode
  • directoryPath (string): Display name of current directory
  • hasPermission (boolean): Whether FSA permission is granted
  • hasDirectoryAccess (boolean): Whether a directory is selected/available
  • error (Error | null): Last error that occurred
  • encryptionEnabled (boolean): Whether encryption is active
  • encryptionReady (boolean | null): Whether encryption is initialized

Mode Switching

  • switchToOPFS(): Switch to OPFS mode
  • switchToFSA(): 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.