@cranberry-money/shared-hooks

v1.0.41

Published

Shared business logic hooks for Blueberry platform - reusable across React and React Native

Readme

@cranberry-money/shared-hooks

Shared business logic hooks for the Blueberry platform. These hooks contain all the state management, validation, and business logic that can be reused across React (Blueberry) and React Native (Blackberry) applications.

Philosophy

This package follows the principle of "shared logic, separate UI":

  • ✅ Business logic, validation, and state management live here
  • ❌ UI components stay in their respective projects
  • ✅ Each platform maintains its own look and feel
  • ✅ Maximum code reuse without compromising platform-specific optimizations

Installation

npm install @cranberry-money/shared-hooks

Dependencies

This package has minimal dependencies:

  • @cranberry-money/shared-types - For TypeScript type definitions
  • @cranberry-money/shared-constants - For business constants (optional)
  • @cranberry-money/shared-utils - For utility functions (optional)

Note: This package does NOT depend on @cranberry-money/shared-services. Service functions are passed to hooks via dependency injection, keeping the packages decoupled.

Usage Examples

In Blueberry (React Web)

// src/pages/signin/SigninPage.tsx
import { useSignin } from '@cranberry-money/shared-hooks';
import { useNavigate } from 'react-router-dom';
import { signin } from '@cranberry-money/shared-services';

export const SigninPage = () => {
  const navigate = useNavigate();

  // Use the shared hook
  const { form, errors, generalError, isLoading, showPassword, setFieldValue, togglePassword, handleSubmit } =
    useSignin({
      onSuccess: () => navigate('/dashboard'),
    });

  // Handle form submission
  const onSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    handleSubmit(signin);
  };

  // Render your UI using the hook's state and methods
  return (
    <div className="signin-container">
      <form onSubmit={onSubmit}>
        {generalError && <div className="error-alert">{generalError}</div>}

        <input
          type="email"
          value={form.email}
          onChange={(e) => setFieldValue('email', e.target.value)}
          className={errors.email ? 'input-error' : ''}
        />
        {errors.email && <span>{errors.email[0]}</span>}

        <input
          type={showPassword ? 'text' : 'password'}
          value={form.password}
          onChange={(e) => setFieldValue('password', e.target.value)}
        />
        <button type="button" onClick={togglePassword}>
          {showPassword ? 'Hide' : 'Show'}
        </button>
        {errors.password && <span>{errors.password[0]}</span>}

        <button type="submit" disabled={isLoading}>
          {isLoading ? 'Signing in...' : 'Sign In'}
        </button>
      </form>
    </div>
  );
};

In Blackberry (React Native)

// src/screens/SignInScreen.tsx
import React from 'react';
import { View, Text, TextInput, TouchableOpacity, Alert } from 'react-native';
import { useSignin } from '@cranberry-money/shared-hooks';
import { signin } from '../services/authService';

export const SignInScreen = ({ navigation }) => {
  // Use the same shared hook
  const { form, errors, generalError, isLoading, showPassword, setFieldValue, togglePassword, handleSubmit } =
    useSignin({
      onSuccess: () => navigation.navigate('Dashboard'),
    });

  // Show alert for general errors
  React.useEffect(() => {
    if (generalError) {
      Alert.alert('Sign In Failed', generalError);
    }
  }, [generalError]);

  // Render your native UI using the hook's state and methods
  return (
    <View style={styles.container}>
      <TextInput
        style={[styles.input, errors.email && styles.inputError]}
        value={form.email}
        onChangeText={(text) => setFieldValue('email', text)}
        placeholder="Email"
        keyboardType="email-address"
        autoCapitalize="none"
      />
      {errors.email && <Text style={styles.error}>{errors.email[0]}</Text>}

      <View style={styles.passwordContainer}>
        <TextInput
          style={[styles.input, errors.password && styles.inputError]}
          value={form.password}
          onChangeText={(text) => setFieldValue('password', text)}
          placeholder="Password"
          secureTextEntry={!showPassword}
        />
        <TouchableOpacity onPress={togglePassword}>
          <Text>{showPassword ? '👁' : '👁‍🗨'}</Text>
        </TouchableOpacity>
      </View>
      {errors.password && <Text style={styles.error}>{errors.password[0]}</Text>}

      <TouchableOpacity
        style={[styles.button, isLoading && styles.buttonDisabled]}
        onPress={() => handleSubmit(signin)}
        disabled={isLoading}
      >
        <Text style={styles.buttonText}>{isLoading ? 'Signing in...' : 'Sign In'}</Text>
      </TouchableOpacity>
    </View>
  );
};

Available Hooks

useSignin

Manages signin form state, validation, and submission.

Options

interface UseSigninOptions {
  onSuccess?: () => void; // Called after successful signin
  onError?: (error: unknown) => void; // Called on signin error
}

Returns

interface UseSigninReturn {
  form: SigninPayload; // Form data
  errors: FormErrors; // Field-specific errors
  generalError: string | null; // General error message
  isLoading: boolean; // Loading state
  showPassword: boolean; // Password visibility state
  setForm: (form: SigninPayload) => void; // Set entire form
  setFieldValue: (field, value) => void; // Set individual field
  clearErrors: () => void; // Clear all errors
  togglePassword: () => void; // Toggle password visibility
  validateForm: () => boolean; // Validate form manually
  handleSubmit: (signinFn) => Promise; // Submit form with signin function
}

Benefits

  1. Write once, use everywhere: Business logic is written once and shared
  2. Platform-appropriate UI: Each platform maintains its native look and feel
  3. Consistent behavior: Same validation, error handling, and state management
  4. Easy to test: Pure business logic separated from UI
  5. Type-safe: Full TypeScript support

Adding New Hooks

When creating new shared hooks:

  1. Create the hook in src/hooks/
  2. Export it from src/hooks/index.ts
  3. Follow the pattern of returning both state and methods
  4. Keep UI concerns out - only business logic
  5. Make the hook flexible with options/callbacks
  6. Accept service functions as parameters (dependency injection) rather than importing them

Example structure:

interface UseYourFeatureOptions {
  onSuccess?: () => void;
  onError?: (error: unknown) => void;
}

export const useYourFeature = (options: UseYourFeatureOptions = {}) => {
  // State management
  const [state, setState] = useState();

  // Business logic methods
  const businessMethod = () => {
    // Logic here
  };

  // Method that accepts service function via dependency injection
  const handleAction = async (serviceFn: (data: any) => Promise<any>) => {
    try {
      const result = await serviceFn(state);
      options.onSuccess?.();
    } catch (error) {
      options.onError?.(error);
    }
  };

  // Return everything the UI needs
  return {
    // State
    state,
    // Methods
    businessMethod,
    handleAction, // Consumer passes their service function here
  };
};

Migration Guide

From Blueberry's Current Implementation

Before (in Blueberry):

// pages/signin/useSigninPage.ts
export const useSigninPage = () => {
  // All logic here
};

// pages/signin/SigninPage.tsx
const { ... } = useSigninPage();

After:

// pages/signin/SigninPage.tsx
import { useSignin } from '@cranberry-money/shared-hooks';

const { ... } = useSignin({
  onSuccess: () => navigate('/dashboard'),
});

From Blackberry's Current Implementation

Before (in Blackberry):

// screens/SignInScreen.tsx
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Validation logic inline

After:

// screens/SignInScreen.tsx
import { useSignin } from '@cranberry-money/shared-hooks';

const { form, setFieldValue, ... } = useSignin({
  onSuccess: () => navigation.navigate('Dashboard'),
});

Testing

Since hooks are pure business logic, they're easy to test:

import { renderHook, act } from '@testing-library/react-hooks';
import { useSignin } from '@cranberry-money/shared-hooks';

test('validates email field', () => {
  const { result } = renderHook(() => useSignin());

  act(() => {
    result.current.setFieldValue('email', 'invalid');
    result.current.validateForm();
  });

  expect(result.current.errors.email).toBeDefined();
});

Contributing

When adding new hooks, ensure they:

  • Contain only business logic (no UI)
  • Are well-typed with TypeScript
  • Include comprehensive JSDoc comments
  • Have corresponding tests
  • Work identically on both platforms