npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rustedcompiler/frappe-hooks

v2.1.1

Published

Reusable HTTP hooks for React and Frappe

Readme

Frappe Hooks

npm version License: MIT

React hooks library for Frappe/ERPNext integration.


Table of Contents

Overview

Dynamic authentication allows your application to:

  • Start without credentials and let users login later
  • Switch between different accounts at runtime
  • Support both password and API token authentication
  • Provide a login/logout flow similar to traditional apps

Setup

Enable Dynamic Authentication

Wrap your app with FrappeProvider and enable the feature:

import { FrappeProvider } from '@rustedcompiler/frappe-hooks';

function App() {
  return (
    <FrappeProvider
      options={{
        url: 'https://your-erpnext-site.com'
        // No credentials initially
      }}
      enableDynamicAuth={true}
    >
      <YourApp />
    </FrappeProvider>
  );
}

FrappeProvider Props

<FrappeProvider
  options={{ url: 'https://your-erpnext-site.com' }}
  enableDynamicAuth={true}
  cacheTTL={300000}
>
  <App />
</FrappeProvider>

| Prop | Type | Default | Description | |------|------|---------|-------------| | options | FrappeClientOptions | required | Base configuration for the Frappe client (see below). | | enableDynamicAuth | boolean | false | Enables runtime credential updates via useAuth. Must be true to use login, logout, and loginWithAPIToken. | | cacheTTL | number | 300000 | Cache time-to-live in milliseconds. Cached responses older than this are discarded and re-fetched. Default is 5 minutes. |

FrappeClientOptions

interface FrappeClientOptions {
  url: string;           // Base URL of your Frappe instance
  token?: string;        // API token in "key:secret" format
  username?: string;     // Username for basic auth
  password?: string;     // Password for basic auth
  useToken?: boolean;    // Whether to use token-based auth
}

| Option | Type | Description | |--------|------|-------------| | url | string | Base URL of your Frappe/ERPNext instance. Required. | | token | string | API token in "key:secret" format. If provided, the client is considered authenticated on mount. | | username | string | Username for session-based auth. If provided alongside password, the client is considered authenticated on mount. | | password | string | Password for session-based auth. | | useToken | boolean | When true, forces token-based authentication. |

cacheTTL Examples

// Default — cache responses for 5 minutes
<FrappeProvider options={{ url: '...' }}>

// Aggressive caching — cache for 30 minutes
<FrappeProvider options={{ url: '...' }} cacheTTL={1800000}>

// No caching — always re-fetch
<FrappeProvider options={{ url: '...' }} cacheTTL={0}>

// Short TTL for frequently changing data
<FrappeProvider options={{ url: '...' }} cacheTTL={10000}>

Cache is automatically cleared when credentials change via updateCredentials or clearCredentials.


Usage Examples

useAuth

import { useAuth } from '@rustedcompiler/frappe-hooks';

function AuthExample() {
  const {
    login,
    loginWithAPIToken,
    dynamicLogin,
    logout,
    getCurrentUser,
    isLoading,
    error,
    isAuthenticated,
  } = useAuth();

  // Session-based login
  await login('Administrator', 'password');

  // Token-based login
  await loginWithAPIToken('api_key', 'api_secret');

  // Dynamic login — accepts either method
  await dynamicLogin({ username: 'Administrator', password: 'password' });
  await dynamicLogin({ apiKey: 'api_key', apiSecret: 'api_secret' });

  // Get current logged in user
  const user = await getCurrentUser();

  // Logout
  await logout();

  return (
    <div>
      <p>Status: {isAuthenticated ? 'Authenticated' : 'Not authenticated'}</p>
      {isLoading && <p>Loading...</p>}
      {error && <p>{error.message}</p>}
    </div>
  );
}

1. Login Form with Username/Password

import { useAuth } from '@rustedcompiler/frappe-hooks';
import { useState } from 'react';

function LoginForm() {
  const { login, isLoading, error, isAuthenticated } = useAuth();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await login(username, password);
      console.log('Login successful!');
    } catch (err) {
      console.error('Login failed:', err);
    }
  };

  if (isAuthenticated) {
    return <div>You are logged in!</div>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <h2>Login to ERPNext</h2>
      <input
        type="text"
        placeholder="Username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
      {error && <p style={{ color: 'red' }}>{error.message}</p>}
    </form>
  );
}

2. Login with API Token

import { useAuth } from '@rustedcompiler/frappe-hooks';
import { useState } from 'react';

function APITokenLogin() {
  const { loginWithAPIToken, isLoading, error } = useAuth();
  const [apiKey, setApiKey] = useState('');
  const [apiSecret, setApiSecret] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await loginWithAPIToken(apiKey, apiSecret);
      console.log('Connected with API token!');
    } catch (err) {
      console.error('Connection failed:', err);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Connect with API Token</h2>
      <input
        type="text"
        placeholder="API Key"
        value={apiKey}
        onChange={(e) => setApiKey(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="API Secret"
        value={apiSecret}
        onChange={(e) => setApiSecret(e.target.value)}
        required
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Connecting...' : 'Connect'}
      </button>
      {error && <p style={{ color: 'red' }}>{error.message}</p>}
    </form>
  );
}

3. Logout

import { useAuth } from '@rustedcompiler/frappe-hooks';

function LogoutButton() {
  const { logout, isLoading, isAuthenticated } = useAuth();

  if (!isAuthenticated) return null;

  return (
    <button onClick={logout} disabled={isLoading}>
      {isLoading ? 'Logging out...' : 'Logout'}
    </button>
  );
}

4. Protected Routes

import { useAuth } from '@rustedcompiler/frappe-hooks';
import { Navigate } from 'react-router-dom';

function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  return <>{children}</>;
}

function App() {
  return (
    <Routes>
      <Route path="/login" element={<LoginForm />} />
      <Route
        path="/dashboard"
        element={
          <ProtectedRoute>
            <Dashboard />
          </ProtectedRoute>
        }
      />
    </Routes>
  );
}

5. Dynamic Login (Both Methods)

import { useAuth } from '@rustedcompiler/frappe-hooks';
import { useState } from 'react';

function FlexibleLogin() {
  const { dynamicLogin, isLoading, error } = useAuth();
  const [authMethod, setAuthMethod] = useState<'password' | 'token'>('password');
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [apiKey, setApiKey] = useState('');
  const [apiSecret, setApiSecret] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      if (authMethod === 'password') {
        await dynamicLogin({ username, password });
      } else {
        await dynamicLogin({ apiKey, apiSecret });
      }
    } catch (err) {
      console.error('Authentication failed:', err);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        <input type="radio" value="password" checked={authMethod === 'password'}
          onChange={() => setAuthMethod('password')} />
        Username & Password
      </label>
      <label>
        <input type="radio" value="token" checked={authMethod === 'token'}
          onChange={() => setAuthMethod('token')} />
        API Token
      </label>
      {authMethod === 'password' ? (
        <>
          <input placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} />
          <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} />
        </>
      ) : (
        <>
          <input placeholder="API Key" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
          <input type="password" placeholder="API Secret" value={apiSecret} onChange={(e) => setApiSecret(e.target.value)} />
        </>
      )}
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Authenticating...' : 'Login'}
      </button>
      {error && <p style={{ color: 'red' }}>{error.message}</p>}
    </form>
  );
}

6. Get Current User

import { useAuth } from '@rustedcompiler/frappe-hooks';

function UserInfo() {
  const { getCurrentUser } = useAuth();
  const [user, setUser] = useState<any>(null);

  React.useEffect(() => {
    getCurrentUser().then(setUser).catch(console.error);
  }, []);

  return <p>Logged in as: {user?.message}</p>;
}

useDocuments

Fetches a list of documents. Re-fetches automatically when doctype or options content changes.

const { data, isLoading, error } = useDocuments(doctype, options);

Parameters

| Parameter | Type | Description | |-----------|------|-------------| | doctype | string | The Frappe doctype to query. | | options | QueryOptions | Optional query options (see QueryOptions reference). |

Returns

| Field | Type | Description | |-------|------|-------------| | data | T[] | Array of documents. Empty array until loaded. | | isLoading | boolean | true while the request is in progress. | | error | Error \| null | Set if the request failed, otherwise null. |

Basic fetch

import { useDocuments } from '@rustedcompiler/frappe-hooks';

function UserList() {
  const { data, isLoading, error } = useDocuments('User', {
    fields: ['name', 'email'],
    limit_page_length: 10,
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.map(user => (
        <li key={user.name}>{user.email}</li>
      ))}
    </ul>
  );
}

With AND / OR filters

// AND filters (default)
const { data } = useDocuments('User', {
  fields: ['name', 'email'],
  filters: [
    ['User', 'enabled', '=', 1],
    ['User', 'user_type', '=', 'System User'],
  ],
});

// OR filters
const { data } = useDocuments('User', {
  fields: ['name', 'email'],
  filters: [
    ['User', 'user_type', '=', 'System User'],
    ['User', 'user_type', '=', 'Website User'],
  ],
  is_or: true,
});

With pagination

const [page, setPage] = useState(0);
const pageSize = 20;

const { data } = useDocuments('Customer', {
  fields: ['name', 'customer_name'],
  limit_start: page * pageSize,
  limit_page_length: pageSize,
  order_by: 'creation desc',
});

With expanded link fields

// "priority" must be in both fields and expand
const { data } = useDocuments('Task', {
  fields: ['name', 'priority'],
  expand: ['priority'],
});

// data[0].priority is now an object, not just an ID
// { name: 'a1b2c3', title: 'Medium', creation: '...' }

All options

const { data } = useDocuments('User', {
  fields: ['name', 'email'],
  filters: [['User', 'enabled', '=', 1]],
  limit_page_length: 10,
  limit_start: 0,
  order_by: 'creation desc',
  expand: ['role'],
  as_dict: true,
  debug: false,
  is_or: false,
});

useDocument

Fetches a single document by name.

const { data, isLoading, error } = useDocument(doctype, name, expandLinks);

Parameters

| Parameter | Type | Description | |-----------|------|-------------| | doctype | string | The Frappe doctype to query. | | name | string | The name/ID of the document to fetch. | | expandLinks | boolean | When true, expands all link fields inline. Default false. |

Returns

| Field | Type | Description | |-------|------|-------------| | data | T \| null | The document, or null until loaded. | | isLoading | boolean | true while the request is in progress. | | error | Error \| null | Set if the request failed, otherwise null. |

Basic fetch

import { useDocument } from '@rustedcompiler/frappe-hooks';

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useDocument('User', userId);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!data) return null;

  return (
    <div>
      <h2>{data.full_name}</h2>
      <p>{data.email}</p>
    </div>
  );
}

With expanded link fields

const { data } = useDocument('Task', 'TASK-0001', true);

// Link fields are returned as objects instead of IDs
// data.assigned_to = { name: '[email protected]', full_name: 'John' }

useCreateDocument

import { useCreateDocument } from '@rustedcompiler/frappe-hooks';

function CreateExample() {
  const { mutate, isLoading, error } = useCreateDocument({
    onSuccess: (data) => console.log('Created:', data),
    onError: (err) => console.error('Failed:', err),
    invalidate: /^docs:/,  // invalidate matching cache keys after creation
  });

  const handleCreate = async () => {
    await mutate({
      docType: 'ToDo',
      data: { description: 'My new todo' },
    });
  };

  return (
    <button onClick={handleCreate} disabled={isLoading}>
      {isLoading ? 'Creating...' : 'Create'}
    </button>
  );
}

useUpdateDocument

import { useUpdateDocument } from '@rustedcompiler/frappe-hooks';

function UpdateExample() {
  const { mutate, isLoading, error } = useUpdateDocument({
    onSuccess: (data) => console.log('Updated:', data),
    onError: (err) => console.error('Failed:', err),
  });

  const handleUpdate = async () => {
    await mutate({
      docType: 'ToDo',
      documentId: 'todo-name-here',
      data: { description: 'Updated description' },
    });
  };

  return (
    <button onClick={handleUpdate} disabled={isLoading}>
      {isLoading ? 'Updating...' : 'Update'}
    </button>
  );
}

useDeleteDocument

import { useDeleteDocument } from '@rustedcompiler/frappe-hooks';

function DeleteExample() {
  const { mutate, isLoading, error } = useDeleteDocument({
    onSuccess: () => console.log('Deleted'),
    onError: (err) => console.error('Failed:', err),
  });

  const handleDelete = async () => {
    await mutate({
      docType: 'ToDo',
      documentId: 'todo-name-here',
    });
  };

  return (
    <button onClick={handleDelete} disabled={isLoading}>
      {isLoading ? 'Deleting...' : 'Delete'}
    </button>
  );
}

useUploadFile

import { useUploadFile } from '@rustedcompiler/frappe-hooks';

function UploadExample() {
  const { upload, isLoading, error, data } = useUploadFile({
    onSuccess: (res) => console.log('Uploaded to:', res.message.file_url),
    onError: (err) => console.error('Failed:', err),
  });

  const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    await upload(file, {
      doctype: 'Customer',   // optional — attach to a document
      docname: 'CUST-0001',  // optional
      fieldname: 'image',    // optional
      isPrivate: true,       // optional — stored in /private/files if true
    });
  };

  return (
    <div>
      <input type="file" onChange={handleChange} disabled={isLoading} />
      {isLoading && <p>Uploading...</p>}
      {error && <p>{error.message}</p>}
      {data && <p>Uploaded: {data.message.file_url}</p>}
    </div>
  );
}

UploadFileOptions

interface UploadFileOptions {
  doctype?: string;    // Attach the file to this doctype
  docname?: string;    // Attach the file to this document
  fieldname?: string;  // The field on the document to attach to
  isPrivate?: boolean; // When true, file is stored in /private/files. Default false.
}

Response Shape

// data.message after a successful upload
{
  name: "0bc595b342",
  file_name: "App.tsx",
  file_url: "/private/files/App.tsx",
  file_size: 811,
  is_private: 1,
  folder: "Home",
  content_hash: "1ec3659508e631575d2e6d5c4d1c41ef",
  doctype: "File",
}

Note: When isPrivate: true, the file_url is under /private/files/ and requires authentication to access. Public files are served directly from /files/.


Complete Examples

Full Authentication Flow

import React, { useState } from 'react';
import {
  FrappeProvider,
  useAuth,
  useDocuments
} from '@rustedcompiler/frappe-hooks';

function Login() {
  const { login, isLoading, error } = useAuth();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    await login(username, password);
  };

  return (
    <form onSubmit={handleLogin}>
      <input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
      <button type="submit" disabled={isLoading}>{isLoading ? 'Logging in...' : 'Login'}</button>
      {error && <p style={{ color: 'red' }}>{error.message}</p>}
    </form>
  );
}

function Dashboard() {
  const { logout } = useAuth();
  const { data: customers } = useDocuments('Customer', {
    fields: ['name', 'customer_name'],
    limit_page_length: 10,
  });

  return (
    <div>
      <button onClick={logout}>Logout</button>
      <ul>
        {customers.map(customer => (
          <li key={customer.name}>{customer.customer_name}</li>
        ))}
      </ul>
    </div>
  );
}

export default function Root() {
  return (
    <FrappeProvider
      options={{ url: 'https://your-erpnext-site.com' }}
      enableDynamicAuth={true}
    >
      <App />
    </FrappeProvider>
  );
}

Security Best Practices

1. Never Hardcode Credentials

// ❌ Bad
const apiKey = 'hardcoded_key';

// ✅ Good - Use environment variables
const apiKey = process.env.REACT_APP_FRAPPE_API_KEY;

// ✅ Better - Get from user input at runtime
const { loginWithAPIToken } = useAuth();
loginWithAPIToken(userProvidedKey, userProvidedSecret);

2. Store Credentials Securely

// ❌ Bad - localStorage is not secure for sensitive data
localStorage.setItem('apiSecret', secret);

// ✅ Good - The library manages credentials in memory only

3. Use HTTPS

// ❌ Bad
<FrappeProvider options={{ url: 'http://unsecure-site.com' }} />

// ✅ Good
<FrappeProvider options={{ url: 'https://secure-site.com' }} />

API Reference

QueryOptions

Options accepted by useDocuments and useDocument.

interface QueryOptions {
  fields?: string[];
  filters?: Filter[];
  limit_start?: number;
  limit_page_length?: number;
  order_by?: string;
  expand?: string[];
  as_dict?: boolean;
  debug?: boolean;
  is_or?: boolean;
}

| Option | Type | Description | |--------|------|-------------| | fields | string[] | Fields to return. Defaults to all fields. | | filters | Filter[] | Array of Frappe-style filters (see below). | | limit_start | number | Offset for pagination. Default 0. | | limit_page_length | number | Number of records to return per page. | | order_by | string | Sort order, e.g. "creation desc". | | expand | string[] | Link fields to expand inline. The field must also be included in fields, otherwise it returns null. | | as_dict | boolean | When false, returns data as List[List] instead of List[dict]. Default true. | | debug | boolean | When true, returns the executed SQL query and execution time under exc in the response. Keep false in production. | | is_or | boolean | When true, combines multiple filters with OR instead of the default AND. |

Filter Format

type Filter = [string, string, string, string | number | boolean];
//             doctype  field   operator  value

Supported operators: =, !=, like, not like, >, <, >=, <=, in, not in.


MutationOptions

Options accepted by useCreateDocument, useUpdateDocument, and useDeleteDocument.

interface MutationOptions<TData> {
  onSuccess?: (data: TData) => void;
  onError?: (error: Error) => void;
  invalidate?: string[] | string | RegExp;
}

| Option | Type | Description | |--------|------|-------------| | onSuccess | (data: TData) => void | Called with the response data on success. | | onError | (error: Error) => void | Called with the error on failure. | | invalidate | string[] \| string \| RegExp | Cache keys to invalidate after mutation. Accepts exact keys, an array of keys, or a regex pattern. |


useAuth()

{
  login: (username: string, password: string) => Promise<any>
  loginWithAPIToken: (apiKey: string, apiSecret: string) => Promise<{ success: true }>
  dynamicLogin: (credentials: LoginCredentials) => Promise<any>
  logout: () => Promise<{ success: true }>
  getCurrentUser: () => Promise<any>
  isLoading: boolean
  error: Error | null
  isAuthenticated: boolean
}
interface LoginCredentials {
  username?: string;
  password?: string;
  apiKey?: string;
  apiSecret?: string;
}

Troubleshooting

Q: Credentials not updating? Ensure enableDynamicAuth={true} is set on FrappeProvider.

Q: Cache showing old data after login? Cache is automatically cleared when credentials change.

Q: Data is stale or not updating? Reduce cacheTTL on FrappeProvider or set it to 0 to disable caching entirely. Cache is keyed by doctype + query options, so changing any query parameter will always produce a fresh fetch regardless of TTL.

Q: How to persist login across page refreshes? The library manages credentials in memory only. Implement your own session restore:

const { loginWithAPIToken } = useAuth();

const storedToken = localStorage.getItem('frappe_token');
if (storedToken) {
  const [apiKey, apiSecret] = storedToken.split(':');
  loginWithAPIToken(apiKey, apiSecret);
}

Q: expand returning null? Make sure the field is included in both fields and expand.

// ❌ Bad — abc not in fields
const { data } = useDocuments('Parent', {
  expand: ['abc'],
});

// ✅ Good
const { data } = useDocuments('Parent', {
  fields: ['name', 'abc'],
  expand: ['abc'],
});

Q: Cookies not being saved after login? Your HTTP client must be configured with credentials: 'include' (fetch) or withCredentials: true (axios) for cross-origin requests. Also ensure your Frappe site_config.json sets allow_cors to your exact frontend origin — a wildcard * will not work with credentialed requests.