CommonUtilsClean - Essential Utility Functions
Metadata
- Path:
utils/CommonUtilsClean.ts - Category: Utility
- Dependencies: None (pure TypeScript/JavaScript)
- Reusability Score: ⭐⭐⭐⭐⭐ (80% - some functions are Indonesian-specific)
- Framework: Framework-agnostic
Description
Collection of 20+ essential utility functions for common operations including text formatting, number conversions, date handling, and React Query configuration presets. These utilities eliminate duplicate code across your application and provide consistent behavior.
Key Features:
- React Query options presets (3 configurations)
- Text formatting (capitalize, titleCase, arrayToSentence)
- Number utilities (toExcelColumn, numberToWords)
- Date utilities (format, combine, convert)
- Data processing (recursive calculations)
- Indonesian-specific formatters (adaptable to other languages)
Use Cases:
- Consistent data formatting across the app
- React Query configuration
- Report generation
- Indonesian localization
- Data transformations
Installation
# No external dependencies required for most functions
# Optional for Indonesian number conversion
yarn add dayjs # For date handling
Complete Code
// utils/CommonUtilsClean.ts
/**
* ============================================
* REACT QUERY OPTIONS PRESETS
* ============================================
*/
/**
* For frequently refetching data (e.g., dashboard, real-time data)
* - Stale after 2 minutes
* - Cached for 5 minutes
* - Refetch on mount
* - No window focus refetch
*/
export const DEFAULT_OPTIONS_BASE = {
staleTime: 2 * 60 * 1000, // 2 minutes
cacheTime: 5 * 60 * 1000, // 5 minutes
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
keepPreviousData: false,
retry: 1,
retryDelay: 2000,
};
/**
* For longer-lived cached data (e.g., master data, reference data)
* - Stale after 5 minutes
* - Cached for 30 minutes
* - No automatic refetching
*/
export const DEFAULT_OPTIONS = {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
keepPreviousData: true,
retry: 1,
retryDelay: 3000,
};
/**
* For always-fresh data (e.g., forms, critical updates)
* - Always considered stale
* - Refetch on every interaction
* - No caching
*/
export const DEFAULT_OPTIONS_NO_CACHE = {
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
cacheTime: 0,
keepPreviousData: false,
retry: 2,
retryDelay: 1000,
};
/**
* ============================================
* TEXT FORMATTING FUNCTIONS
* ============================================
*/
/**
* Capitalize text with special handling for titles and abbreviations
*
* @param text - Text to capitalize
* @returns Capitalized text
*
* @example
* Capitalize("john DOE") // "John Doe"
* Capitalize("SATPOL PP") // "Satpol Pp"
* Capitalize("IT department") // "It Department"
*/
export const Capitalize = (text: string): string => {
if (!text) return '';
return text
.toLowerCase()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
/**
* Convert snake_case or kebab-case to Title Case
*
* @param str - String with underscores or hyphens
* @returns Title cased string
*
* @example
* toTitleCase("user_name") // "User Name"
* toTitleCase("user-name") // "User Name"
* toTitleCase("USER_NAME") // "User Name"
*/
export const toTitleCase = (str: string): string => {
if (!str) return '';
return str
.replace(/[_-]/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
/**
* Convert array to sentence with "and" (or "dan" for Indonesian)
*
* @param items - Array of strings
* @param conjunction - Word to use for conjunction (default: "dan" for Indonesian)
* @returns Sentence string
*
* @example
* arrayToSentence(["A", "B", "C"]) // "A, B dan C"
* arrayToSentence(["A", "B", "C"], "and") // "A, B and C"
* arrayToSentence(["A", "B"]) // "A dan B"
* arrayToSentence(["A"]) // "A"
*/
export const arrayToSentence = (
items: string[],
conjunction: string = 'dan'
): string => {
if (items.length === 0) return '';
if (items.length === 1) return items[0];
if (items.length === 2) return items.join(` ${conjunction} `);
return items.slice(0, -1).join(', ') + ` ${conjunction} ` + items[items.length - 1];
};
/**
* ============================================
* NUMBER UTILITIES
* ============================================
*/
/**
* Convert number to Excel column letter
*
* @param num - Column number (1-based)
* @returns Column letter(s)
*
* @example
* numberToExcelColumn(1) // "A"
* numberToExcelColumn(27) // "AA"
* numberToExcelColumn(702) // "ZZ"
* numberToExcelColumn(703) // "AAA"
*/
export const numberToExcelColumn = (num: number): string => {
let column = '';
while (num > 0) {
const remainder = (num - 1) % 26;
column = String.fromCharCode(65 + remainder) + column;
num = Math.floor((num - remainder) / 26);
}
return column;
};
/**
* Convert number to Indonesian words
*
* @param num - Number to convert
* @returns Indonesian words representation
*
* @example
* numberToWords(123) // "Seratus Dua Puluh Tiga"
* numberToWords(1000000) // "Satu Juta"
* numberToWords(2500) // "Dua Ribu Lima Ratus"
*/
export const numberToWords = (num: number): string => {
if (num === 0) return 'Nol';
const ones = ['', 'Satu', 'Dua', 'Tiga', 'Empat', 'Lima', 'Enam', 'Tujuh', 'Delapan', 'Sembilan'];
const teens = ['Sepuluh', 'Sebelas', 'Dua Belas', 'Tiga Belas', 'Empat Belas', 'Lima Belas',
'Enam Belas', 'Tujuh Belas', 'Delapan Belas', 'Sembilan Belas'];
const tens = ['', '', 'Dua Puluh', 'Tiga Puluh', 'Empat Puluh', 'Lima Puluh',
'Enam Puluh', 'Tujuh Puluh', 'Delapan Puluh', 'Sembilan Puluh'];
const convertBelowThousand = (n: number): string => {
if (n === 0) return '';
if (n < 10) return ones[n];
if (n < 20) return teens[n - 10];
if (n < 100) {
const ten = Math.floor(n / 10);
const one = n % 10;
return tens[ten] + (one > 0 ? ' ' + ones[one] : '');
}
const hundred = Math.floor(n / 100);
const rest = n % 100;
const hundredWord = hundred === 1 ? 'Seratus' : ones[hundred] + ' Ratus';
return hundredWord + (rest > 0 ? ' ' + convertBelowThousand(rest) : '');
};
if (num < 1000) return convertBelowThousand(num);
if (num < 1000000) {
const thousand = Math.floor(num / 1000);
const rest = num % 1000;
const thousandWord = thousand === 1 ? 'Seribu' : convertBelowThousand(thousand) + ' Ribu';
return thousandWord + (rest > 0 ? ' ' + convertBelowThousand(rest) : '');
}
if (num < 1000000000) {
const million = Math.floor(num / 1000000);
const rest = num % 1000000;
const millionWord = convertBelowThousand(million) + ' Juta';
return millionWord + (rest > 0 ? ' ' + numberToWords(rest) : '');
}
const billion = Math.floor(num / 1000000000);
const rest = num % 1000000000;
const billionWord = convertBelowThousand(billion) + ' Miliar';
return billionWord + (rest > 0 ? ' ' + numberToWords(rest) : '');
};
/**
* ============================================
* DATE UTILITIES
* ============================================
*/
/**
* Convert value to Date or null
* Handles Date objects, ISO strings, and invalid values
*
* @param v - Value to convert
* @returns Date object or null
*
* @example
* toDateOrNull(new Date()) // Date object
* toDateOrNull("2025-01-15") // Date object
* toDateOrNull(null) // null
* toDateOrNull("invalid") // null
*/
export const toDateOrNull = (v: Date | string | null | undefined): Date | null => {
if (!v) return null;
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
const date = new Date(v);
return isNaN(date.getTime()) ? null : date;
};
/**
* Combine ISO date with time string
*
* @param isoDate - ISO date string (YYYY-MM-DD)
* @param time - Time string (HH:mm)
* @returns Combined ISO datetime string
*
* @example
* combineDateAndTime("2025-01-15", "14:30") // "2025-01-15T14:30:00.000Z"
* combineDateAndTime("2025-01-15", "09:00") // "2025-01-15T09:00:00.000Z"
*/
export const combineDateAndTime = (isoDate: string, time: string): string => {
if (!isoDate || !time) return '';
const [hours, minutes] = time.split(':').map(Number);
const date = new Date(isoDate);
date.setHours(hours, minutes, 0, 0);
return date.toISOString();
};
/**
* Format date to Indonesian format with detailed breakdown
*
* @param date - Date object to format
* @returns Object with various Indonesian date formats
*
* @example
* formatTimeIndonesia(new Date("2025-01-15"))
* // {
* // hari: "Rabu",
* // tanggal: 15,
* // tanggal_indonesia: "Lima Belas",
* // bulan: "Januari",
* // tahun_indonesia: "Dua Ribu Dua Puluh Lima",
* // tahun: 2025,
* // roman: "I"
* // }
*/
export const formatTimeIndonesia = (date: Date) => {
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
const months = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
const romans = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII'];
return {
hari: days[date.getDay()],
tanggal: date.getDate(),
tanggal_indonesia: numberToWords(date.getDate()),
bulan: months[date.getMonth()],
tahun_indonesia: numberToWords(date.getFullYear()),
tahun: date.getFullYear(),
roman: romans[date.getMonth()],
};
};
/**
* Get month name in Indonesian
*
* @param monthNumber - Month number (1-12)
* @returns Indonesian month name
*
* @example
* getMonthName(1) // "Januari"
* getMonthName(12) // "Desember"
*/
export const getMonthName = (monthNumber: number): string => {
const months = [
'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'
];
return months[monthNumber - 1] || '';
};
/**
* Get day name in Indonesian
*
* @param dayNumber - Day number (0=Sunday, 6=Saturday)
* @returns Indonesian day name
*
* @example
* getDayName(0) // "Minggu"
* getDayName(1) // "Senin"
*/
export const getDayName = (dayNumber: number): string => {
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu'];
return days[dayNumber] || '';
};
/**
* ============================================
* DATA PROCESSING
* ============================================
*/
/**
* Recursively calculate sum of values in nested data structures
* Useful for tree/hierarchical data with children
*
* @param data - Data object with potential children
* @param field - Field name to sum
* @param subField - Optional nested field name
* @returns Total sum
*
* @example
* const data = {
* amount: 100,
* children: [
* { amount: 50, children: [] },
* { amount: 25, children: [] }
* ]
* };
* calculateRecursiveValue(data, 'amount') // 175
*/
export const calculateRecursiveValue = (
data: any,
field: string,
subField?: string
): number => {
let total = 0;
// Add current node value
if (subField && data[field]?.[subField]) {
total += Number(data[field][subField]) || 0;
} else if (data[field]) {
total += Number(data[field]) || 0;
}
// Recursively add children values
if (data.children && Array.isArray(data.children)) {
data.children.forEach((child: any) => {
total += calculateRecursiveValue(child, field, subField);
});
}
return total;
};
/**
* Deep clone an object (simple implementation)
*
* @param obj - Object to clone
* @returns Cloned object
*
* @example
* const original = { a: 1, b: { c: 2 } };
* const cloned = deepClone(original);
* cloned.b.c = 3;
* console.log(original.b.c); // Still 2
*/
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as any;
if (obj instanceof Array) return obj.map(item => deepClone(item)) as any;
const clonedObj = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
};
/**
* Check if value is empty (null, undefined, empty string, empty array, empty object)
*
* @param value - Value to check
* @returns True if empty
*
* @example
* isEmpty(null) // true
* isEmpty('') // true
* isEmpty([]) // true
* isEmpty({}) // true
* isEmpty('hello') // false
* isEmpty([1, 2]) // false
*/
export const isEmpty = (value: any): boolean => {
if (value == null) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
};
/**
* ============================================
* STRING UTILITIES
* ============================================
*/
/**
* Truncate string with ellipsis
*
* @param str - String to truncate
* @param maxLength - Maximum length
* @returns Truncated string
*
* @example
* truncate("Hello World", 8) // "Hello..."
* truncate("Hi", 10) // "Hi"
*/
export const truncate = (str: string, maxLength: number): string => {
if (!str || str.length <= maxLength) return str;
return str.substring(0, maxLength - 3) + '...';
};
/**
* Generate random string
*
* @param length - Length of string
* @returns Random alphanumeric string
*
* @example
* randomString(8) // "a7Bx9K2m"
*/
export const randomString = (length: number): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
/**
* Slugify string (URL-friendly)
*
* @param str - String to slugify
* @returns Slugified string
*
* @example
* slugify("Hello World!") // "hello-world"
* slugify("Test & Demo") // "test-demo"
*/
export const slugify = (str: string): string => {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
/**
* ============================================
* VALIDATION UTILITIES
* ============================================
*/
/**
* Validate Indonesian phone number
*
* @param phone - Phone number to validate
* @returns True if valid
*
* @example
* isValidPhone("081234567890") // true
* isValidPhone("+6281234567890") // true
* isValidPhone("1234") // false
*/
export const isValidPhone = (phone: string): boolean => {
const phoneRegex = /^(\+62|62|0)[0-9]{9,12}$/;
return phoneRegex.test(phone);
};
/**
* Validate email address
*
* @param email - Email to validate
* @returns True if valid
*
* @example
* isValidEmail("test@example.com") // true
* isValidEmail("invalid") // false
*/
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
* ============================================
* EXPORTS
* ============================================
*/
export default {
// Query options
DEFAULT_OPTIONS_BASE,
DEFAULT_OPTIONS,
DEFAULT_OPTIONS_NO_CACHE,
// Text formatting
Capitalize,
toTitleCase,
arrayToSentence,
truncate,
slugify,
// Number utilities
numberToExcelColumn,
numberToWords,
// Date utilities
toDateOrNull,
combineDateAndTime,
formatTimeIndonesia,
getMonthName,
getDayName,
// Data processing
calculateRecursiveValue,
deepClone,
isEmpty,
// String utilities
randomString,
// Validation
isValidPhone,
isValidEmail,
};
Usage Examples
React Query Options
import { DEFAULT_OPTIONS, DEFAULT_OPTIONS_BASE, DEFAULT_OPTIONS_NO_CACHE } from '@/utils/CommonUtilsClean';
// For master data (rarely changes)
const { data: provinces } = useQuery(
['provinces'],
fetchProvinces,
DEFAULT_OPTIONS
);
// For dashboard data (frequently updates)
const { data: stats } = useQuery(
['stats'],
fetchStats,
DEFAULT_OPTIONS_BASE
);
// For form data (always fresh)
const { data: formData } = useQuery(
['form', id],
() => fetchFormData(id),
DEFAULT_OPTIONS_NO_CACHE
);
Text Formatting
import { Capitalize, toTitleCase, arrayToSentence } from '@/utils/CommonUtilsClean';
// Capitalize names
const userName = Capitalize("john DOE"); // "John Doe"
// Convert field names
const fieldLabel = toTitleCase("created_at"); // "Created At"
// List items
const items = ["Apple", "Banana", "Orange"];
const sentence = arrayToSentence(items); // "Apple, Banana dan Orange"
Number to Words (Indonesian)
import { numberToWords } from '@/utils/CommonUtilsClean';
// For documents/receipts
const amount = 1500000;
const amountInWords = numberToWords(amount);
// "Satu Juta Lima Ratus Ribu"
// For forms
<Text>Terbilang: {numberToWords(formik.values.amount)}</Text>
Date Formatting
import { formatTimeIndonesia, getMonthName } from '@/utils/CommonUtilsClean';
const date = new Date("2025-01-15");
const indo = formatTimeIndonesia(date);
// Use in documents
<Text>
{indo.hari}, {indo.tanggal} {indo.bulan} {indo.tahun}
</Text>
// Output: "Rabu, 15 Januari 2025"
// Month selector
const months = Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: getMonthName(i + 1)
}));
Recursive Calculations
import { calculateRecursiveValue } from '@/utils/CommonUtilsClean';
const orgStructure = {
name: "Head Office",
budget: 1000000,
children: [
{
name: "Branch A",
budget: 500000,
children: [
{ name: "Sub A1", budget: 200000, children: [] }
]
},
{ name: "Branch B", budget: 300000, children: [] }
]
};
const totalBudget = calculateRecursiveValue(orgStructure, 'budget');
// 2000000 (1000000 + 500000 + 200000 + 300000)
Customization Guide
Adapting for Different Languages
Replace Indonesian-specific functions with your language:
// English version of numberToWords
export const numberToWordsEN = (num: number): string => {
const ones = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine'];
const teens = ['Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen',
'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen'];
// ... implementation
};
// Spanish months
export const getMonthNameES = (monthNumber: number): string => {
const months = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
return months[monthNumber - 1] || '';
};
Custom Query Options
// Very short cache for real-time data
export const REALTIME_OPTIONS = {
staleTime: 0,
cacheTime: 30 * 1000, // 30 seconds
refetchInterval: 5000, // Refetch every 5 seconds
refetchOnWindowFocus: true,
};
// Long-term cache for static data
export const STATIC_OPTIONS = {
staleTime: Infinity,
cacheTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
};
Common Pitfalls
1. Number to Words with Large Numbers
// ❌ May cause performance issues
const hugeNumber = 999999999999;
numberToWords(hugeNumber); // Very slow
// ✅ Add limits
const safeNumberToWords = (num: number): string => {
if (num > 999999999) return "Number too large";
return numberToWords(num);
};
2. Date Timezone Issues
// ❌ May give wrong date
const date = new Date("2025-01-15"); // Might be 14th depending on timezone
// ✅ Use explicit time
const date = new Date("2025-01-15T00:00:00");
// Or use dayjs
import dayjs from 'dayjs';
const date = dayjs("2025-01-15").toDate();
3. Recursive Calculation Stack Overflow
// ❌ May cause stack overflow with deep nesting
calculateRecursiveValue(veryDeepData, 'field');
// ✅ Add depth limit
export const calculateRecursiveValue = (
data: any,
field: string,
subField?: string,
maxDepth: number = 100,
currentDepth: number = 0
): number => {
if (currentDepth > maxDepth) return 0;
// ... rest of implementation
// Pass currentDepth + 1 to recursive calls
};
Performance Considerations
Memoization for Expensive Operations
import { useMemo } from 'react';
// ❌ Recalculates on every render
const Component = ({ data }) => {
const total = calculateRecursiveValue(data, 'amount');
return <div>{total}</div>;
};
// ✅ Memoized
const Component = ({ data }) => {
const total = useMemo(
() => calculateRecursiveValue(data, 'amount'),
[data]
);
return <div>{total}</div>;
};
Related Components
- FormatRupiah - Currency formatting (complements number utilities)
- ConvertDate - Additional date conversion utilities
- useFilterSortHandler - Uses DEFAULT_OPTIONS presets
- DataTableIndex - Uses text formatting functions
Testing
import { describe, it, expect } from '@jest/globals';
import {
Capitalize,
numberToExcelColumn,
isValidEmail,
isEmpty
} from './CommonUtilsClean';
describe('CommonUtilsClean', () => {
describe('Capitalize', () => {
it('capitalizes first letter of each word', () => {
expect(Capitalize('john doe')).toBe('John Doe');
expect(Capitalize('JOHN DOE')).toBe('John Doe');
});
});
describe('numberToExcelColumn', () => {
it('converts numbers to Excel columns', () => {
expect(numberToExcelColumn(1)).toBe('A');
expect(numberToExcelColumn(27)).toBe('AA');
expect(numberToExcelColumn(702)).toBe('ZZ');
});
});
describe('isValidEmail', () => {
it('validates email addresses', () => {
expect(isValidEmail('test@example.com')).toBe(true);
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('test@')).toBe(false);
});
});
describe('isEmpty', () => {
it('checks if values are empty', () => {
expect(isEmpty(null)).toBe(true);
expect(isEmpty('')).toBe(true);
expect(isEmpty([])).toBe(true);
expect(isEmpty({})).toBe(true);
expect(isEmpty('hello')).toBe(false);
expect(isEmpty([1])).toBe(false);
});
});
});
License
MIT - Free to use in any project
Support
For issues or questions, refer to the main documentation in REUSABLE_COMPONENTS_GUIDE.md