Skip to content

Combining Providers

Fluxus shines when you start combining different provider types to build complex state logic in a declarative and maintainable way. This guide explores common patterns for combining providers.

Computed State from Async Data

A frequent use case is deriving synchronous state from asynchronous data. For example, filtering a list fetched from an API.

typescript
import { asyncProvider, AsyncValue, computedProvider, stateProvider } from 'fluxus';

interface User {
  id: number;
  name: string;
}

// 1. Provider to fetch users
const usersProvider = asyncProvider<User[]>(
  async ({ signal }) => {
    const response = await fetch('/api/users', { signal });
    if (!response.ok) {
      throw new Error('Failed to fetch users');
    }
    return response.json();
  },
  { name: 'usersProvider' }
);

// 2. Provider for the search term
const userSearchQueryProvider = stateProvider('', {
  name: 'userSearchQueryProvider',
});

// 3. Computed provider for filtered users
const filteredUsersProvider = computedProvider<User[]>(
  (reader) => {
    const searchQuery = reader.read(userSearchQueryProvider).toLowerCase();
    const usersResult = reader.read(usersProvider); // Read the AsyncValue

    // Handle loading/error states from the async provider
    if (usersResult.state !== 'data') {
      return []; // Return empty list while loading or if there's an error
    }

    // Filter the data based on the search query
    const allUsers = usersResult.data;
    if (!searchQuery) {
      return allUsers; // No query? Return all users.
    }

    return allUsers.filter((user) => user.name.toLowerCase().includes(searchQuery));
  },
  { name: 'filteredUsersProvider' }
);

// --- In your React component ---
// import { useProvider, useProviderUpdater } from '@fluxus/react-adapter';
//
// function UserSearch() {
//   const filteredUsers = useProvider(filteredUsersProvider);
//   const usersResult = useProvider(usersProvider); // To show loading/error states
//   const setSearchQuery = useProviderUpdater(userSearchQueryProvider);
//
//   return (
//     <div>
//       <input
//         type="text"
//         placeholder="Search users..."
//         onChange={(e) => setSearchQuery(e.target.value)}
//       />
//       {usersResult.state === 'loading' && <p>Loading users...</p>}
//       {usersResult.state === 'error' && <p>Error: {usersResult.error.message}</p>}
//       {usersResult.state === 'data' && (
//         <ul>
//           {filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
//         </ul>
//       )}
//     </div>
//   );
// }

Async Operations Triggered by State Changes

You might want to trigger an API call or another async task whenever a piece of state changes.

typescript
import { asyncProvider, scope, stateProvider } from 'fluxus';

interface FormData {
  name: string;
  email: string;
}

// 1. Provider for the form data
const formDataProvider = stateProvider<FormData>(
  { name: '', email: '' },
  {
    name: 'formDataProvider',
  }
);

// 2. Provider to handle the auto-save operation
//    We use an asyncProvider that *watches* the form data.
//    When formDataProvider changes, this provider re-runs.
const autoSaveProvider = asyncProvider<void, { debounceMs?: number }>(
  async (reader, { debounceMs = 500 }) => {
    // Read the current form data. This establishes the dependency.
    const currentData = reader.read(formDataProvider);

    // Simple debounce implementation (in a real app, use a robust debounce function)
    await new Promise((resolve) => setTimeout(resolve, debounceMs));

    // Check if data actually changed since debounce started (optional but good practice)
    // This requires reading the state *again* after the debounce.
    // Note: This simple example doesn't handle race conditions perfectly.
    const latestData = reader.read(formDataProvider);
    if (JSON.stringify(currentData) !== JSON.stringify(latestData)) {
      console.log('Data changed during debounce, skipping save for this trigger.');
      return; // Don't save if data changed rapidly
    }

    if (latestData.name || latestData.email) {
      // Only save if there's data
      console.log('Auto-saving data:', latestData);
      try {
        const response = await fetch('/api/save-form', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(latestData),
          signal: reader.signal, // Pass the signal for cancellation
        });
        if (!response.ok) {
          throw new Error('Auto-save failed');
        }
        console.log('Auto-save successful');
      } catch (error) {
        if (error.name !== 'AbortError') {
          console.error('Auto-save error:', error);
          // Handle error appropriately (e.g., show notification)
        } else {
          console.log('Auto-save aborted.');
        }
        // Re-throw to potentially mark the provider as errored,
        // though in this case, we might just log and continue.
        // throw error;
      }
    } else {
      console.log('No data to auto-save.');
    }
  },
  { name: 'autoSaveProvider' }
);

// --- How to use it ---
// You typically wouldn't *read* the autoSaveProvider directly in the UI,
// as its value (void) isn't usually displayed. Instead, you ensure it's
// active within the scope so it runs when its dependencies change.

// 1. Instantiate the scope
// const appScope = scope();

// 2. Ensure the provider is initialized (e.g., by reading it once, though
//    this isn't ideal as it returns AsyncValue<void>). A better approach
//    might be needed in the core library for "fire-and-forget" providers
//    that just need to react. For now, reading it works to activate it.
// appScope.read(autoSaveProvider);

// 3. Update the form data provider elsewhere in your app
// const updateFormData = appScope.updater(formDataProvider);
// updateFormData(prev => ({ ...prev, name: 'New Name' }));
// --> This change will trigger the autoSaveProvider to re-run after debounce.

Using Utilities like pipe or debounce

You can compose utility functions with your providers to add behaviors like debouncing.

typescript
import { computedProvider, stateProvider } from 'fluxus';
import { debounce } from 'fluxus/utils'; // Assuming utils export debounce
// Or: import { debounce } from '../../src/utils/debounce'; // Adjust path if needed

// 1. Provider for raw input
const rawInputProvider = stateProvider('', { name: 'rawInputProvider' });

// 2. Debounced version of the input provider
//    This example demonstrates debouncing a *side effect* triggered by a provider,
//    rather than debouncing the provider's value itself, which is more complex.
//    See the 'Async Operations Triggered by State Changes' example for a similar pattern.

const debouncedLoggingProvider = computedProvider<string>(
  (reader) => {
    const rawInput = reader.read(rawInputProvider); // Read to establish dependency

    // Create a debounced logging function.
    // IMPORTANT CAVEAT: In this simple computedProvider example, this debounced
    // function is recreated every time the rawInput changes and the provider
    // recomputes. This means the debounce timer resets on *every keystroke*.
    // This is generally NOT the desired behavior for debouncing input effects.
    //
    // A more robust solution would involve:
    //   a) A stateful provider that holds the debounced function internally.
    //   b) Using the `onDispose` mechanism within a provider to clean up timers.
    //   c) Potentially a dedicated `debouncedEffectProvider` or similar utility.
    //
    // This example primarily shows the *concept* of using the debounce utility
    // in the context of providers, highlighting the challenges with simple computed.
    const debouncedLog = debounce((value: string) => {
      if (value) {
        // Only log if there's input
        console.log('Debounced input (logged from computedProvider):', value);
      }
    }, 750); // 750ms debounce

    // Call the debounced function with the current input
    debouncedLog(rawInput);

    // The computed provider itself just returns the raw input immediately.
    // The *side effect* (logging) is what's being debounced (though imperfectly here).
    return rawInput;
  },
  { name: 'debouncedLoggingProvider' }
);

// --- Using the raw input and triggering the debounced log ---
// import { useProvider, useProviderUpdater } from '@fluxus/react-adapter';
//
// function DebouncedLoggerComponent() {
//   // Read the computed provider primarily to activate its computation logic
//   // which includes the debounced side effect. We might not use its return value directly.
//   useProvider(debouncedLoggingProvider);
//   const setRawInput = useProviderUpdater(rawInputProvider);
//
//   console.log('Rendering DebouncedLoggerComponent'); // See how often this renders
//
//   return (
//     <input
//       type="text"
//       placeholder="Type here for debounced log..."
//       onChange={(e) => setRawInput(e.target.value)}
//     />
//   );
// }

// --- Using pipe (Conceptual - requires pipe implementation for providers) ---
/*
import { pipe } from 'fluxus/utils'; // Assuming pipe exists and works on providers

const numberProvider = stateProvider(0);

// Conceptual: Create a provider that doubles the number
const doubleProvider = pipe(
  numberProvider,
  mapProvider(n => n * 2) // Assuming a 'mapProvider' operator exists
);

// Conceptual: Create a provider that debounces updates to the value
const debouncedValueProvider = pipe(
  numberProvider,
  debounceProviderValue(500) // Assuming a value debounce operator exists
);
*/
// Note: Implementing `pipe` and operators like `mapProvider` or `debounceProviderValue`
// that transform providers themselves requires careful design. The existing `pipe`
// utility is likely for general function composition. The `debounce` utility is
// for debouncing function calls, often used for side effects as shown above.

Streams Depending on State

A powerful pattern is creating streams whose source depends on other state. For example, subscribing to a WebSocket topic or a real-time database query based on a selected item ID.

typescript
import { AsyncValue, stateProvider, streamProvider } from 'fluxus';

// Assume a WebSocket utility exists
declare function createWebSocketStream<T>(url: string): ReadableStream<T>;

// 1. Provider for the currently selected item ID (could be null)
const selectedItemIdProvider = stateProvider<number | null>(null, {
  name: 'selectedItemIdProvider',
});

// 2. Stream provider for messages related to the selected item
const itemMessagesProvider = streamProvider<AsyncValue<string>>(
  (reader) => {
    const itemId = reader.read(selectedItemIdProvider);

    // If no item is selected, return a stream that emits nothing or an initial state
    if (itemId === null) {
      // Option 1: Return an empty stream that closes immediately
      // return new ReadableStream({ start(controller) { controller.close(); } });

      // Option 2: Return a stream indicating 'no selection'
      return new ReadableStream({
        start(controller) {
          controller.enqueue({ state: 'data', data: 'No item selected' });
          // Keep the stream open or close it, depending on desired behavior
          // controller.close();
        },
      });
    }

    // Create the actual WebSocket stream based on the ID
    const wsUrl = `wss://example.com/items/${itemId}`;
    console.log(`Subscribing to ${wsUrl}`);
    const stream = createWebSocketStream<string>(wsUrl);

    // Important: Ensure cleanup when the provider is disposed or the ID changes
    // The streamProvider handles closing the subscription, but explicit cleanup
    // might be needed for the underlying WebSocket connection if not handled by createWebSocketStream.
    reader.onDispose(() => {
      console.log(`Unsubscribing from ${wsUrl}`);
      // Add any specific WebSocket closing logic here if needed
    });

    // Map the raw stream data to AsyncValue<string>
    // (Assuming createWebSocketStream provides raw strings)
    // A more robust implementation would handle connection errors.
    return stream.pipeThrough(
      new TransformStream({
        transform(chunk, controller) {
          controller.enqueue({ state: 'data', data: chunk });
        },
        // Handle stream errors
        flush(controller) {
          // Handle stream closing if needed
        },
      })
    );
  },
  { name: 'itemMessagesProvider' }
);

// --- Usage in React ---
// import { useProvider, useProviderUpdater } from '@fluxus/react-adapter';
//
// function ItemMessagesDisplay() {
//   const messagesResult = useProvider(itemMessagesProvider);
//   const setSelectedItemId = useProviderUpdater(selectedItemIdProvider);
//
//   return (
//     <div>
//       <button onClick={() => setSelectedItemId(1)}>Select Item 1</button>
//       <button onClick={() => setSelectedItemId(2)}>Select Item 2</button>
//       <button onClick={() => setSelectedItemId(null)}>Deselect</button>
//
//       <h3>Messages:</h3>
//       {messagesResult.state === 'loading' && <p>Connecting...</p>}
//       {messagesResult.state === 'error' && <p>Error: {messagesResult.error.message}</p>}
//       {messagesResult.state === 'data' && <p>{messagesResult.data}</p>}
//       {/* You might want to accumulate messages in a stateProvider */}
//     </div>
//   );
// }

(More examples to come...)

Released under the ISC License.