- Published on
Membangun UI Components Modern dengan React untuk Developer Bekasi
- Authors
- Name
- Anonymous Developer
- @bekasidev
Membangun UI Components Modern dengan React untuk Developer Bekasi
Dalam dunia frontend development, kemampuan membangun UI components yang reusable dan maintainable adalah skill yang sangat berharga. Tutorial ini akan membahas bagaimana membangun component library yang modern untuk project-project di Bekasi.
๐ฏ Mengapa Component-Based Architecture?
Keuntungan untuk Developer Bekasi
- Reusability: Write once, use everywhere
- Consistency: UI yang konsisten across applications
- Maintainability: Easier to update and debug
- Team Collaboration: Clear separation of concerns
- Faster Development: Pre-built components speed up development
Real-World Example: Bekasi E-Commerce Platform
Mari kita bangun component library untuk platform e-commerce lokal Bekasi dengan komponen:
- Product cards untuk toko online
- Form components untuk checkout
- Navigation untuk multi-vendor marketplace
- Dashboard components untuk seller
๐ Project Setup
Development Environment
# Create React app dengan TypeScript
npx create-react-app bekasi-components --template typescript
cd bekasi-components
# Install dependencies
npm install styled-components framer-motion
npm install -D @types/styled-components storybook
Project Structure
src/
โโโ components/
โ โโโ atoms/ # Basic building blocks
โ โโโ molecules/ # Simple combinations
โ โโโ organisms/ # Complex UI sections
โ โโโ templates/ # Page layouts
โโโ hooks/ # Custom React hooks
โโโ utils/ # Utility functions
โโโ types/ # TypeScript definitions
โโโ styles/ # Global styles & themes
TypeScript Configuration
// src/types/index.ts
export interface Product {
id: string;
name: string;
price: number;
image: string;
category: string;
seller: Seller;
rating: number;
isLocal: boolean;
}
export interface Seller {
id: string;
name: string;
location: string;
verified: boolean;
bekasiSeller: boolean;
}
export interface ComponentProps {
className?: string;
children?: React.ReactNode;
}
๐จ Design System & Theming
Theme Configuration
// src/styles/theme.ts
export const theme = {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
bekasi: {
green: '#22c55e', // Khas warna Bekasi
gold: '#fbbf24', // Accent color
navy: '#1e3a8a', // Professional
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
500: '#6b7280',
900: '#111827',
},
success: '#10b981',
error: '#ef4444',
warning: '#f59e0b',
},
spacing: {
xs: '0.25rem', // 4px
sm: '0.5rem', // 8px
md: '1rem', // 16px
lg: '1.5rem', // 24px
xl: '2rem', // 32px
'2xl': '3rem', // 48px
},
typography: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
serif: ['Merriweather', 'serif'],
},
fontSize: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
},
borderRadius: {
none: '0',
sm: '0.125rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
full: '9999px',
},
shadows: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1)',
},
};
export type Theme = typeof theme;
Styled Components Setup
// src/styles/styled.d.ts
import 'styled-components';
import { Theme } from './theme';
declare module 'styled-components' {
export interface DefaultTheme extends Theme {}
}
// src/styles/GlobalStyles.ts
import styled, { createGlobalStyle } from 'styled-components';
export const GlobalStyles = createGlobalStyle`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: ${({ theme }) => theme.typography.fontFamily.sans.join(', ')};
font-size: ${({ theme }) => theme.typography.fontSize.base};
line-height: 1.5;
color: ${({ theme }) => theme.colors.gray[900]};
background-color: ${({ theme }) => theme.colors.gray[50]};
}
button {
cursor: pointer;
border: none;
background: none;
font-family: inherit;
}
input, textarea {
font-family: inherit;
}
`;
๐งฑ Atomic Design Components
Atoms (Basic Building Blocks)
Button Component
// src/components/atoms/Button/Button.tsx
import React from 'react';
import styled, { css } from 'styled-components';
import { ComponentProps } from '../../../types';
export interface ButtonProps extends ComponentProps {
variant?: 'primary' | 'secondary' | 'bekasi' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
onClick?: () => void;
}
const StyledButton = styled.button<ButtonProps>`
display: inline-flex;
align-items: center;
justify-content: center;
gap: ${({ theme }) => theme.spacing.sm};
border-radius: ${({ theme }) => theme.borderRadius.md};
font-weight: ${({ theme }) => theme.typography.fontWeight.medium};
transition: all 0.2s ease-in-out;
position: relative;
${({ disabled }) => disabled && css`
opacity: 0.5;
cursor: not-allowed;
`}
${({ fullWidth }) => fullWidth && css`
width: 100%;
`}
/* Size variants */
${({ size = 'md', theme }) => {
switch (size) {
case 'sm':
return css`
padding: ${theme.spacing.sm} ${theme.spacing.md};
font-size: ${theme.typography.fontSize.sm};
`;
case 'lg':
return css`
padding: ${theme.spacing.md} ${theme.spacing.xl};
font-size: ${theme.typography.fontSize.lg};
`;
default:
return css`
padding: ${theme.spacing.md} ${theme.spacing.lg};
font-size: ${theme.typography.fontSize.base};
`;
}
}}
/* Color variants */
${({ variant = 'primary', theme }) => {
switch (variant) {
case 'bekasi':
return css`
background: linear-gradient(135deg, ${theme.colors.bekasi.green}, ${theme.colors.bekasi.gold});
color: white;
box-shadow: ${theme.shadows.md};
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: ${theme.shadows.lg};
}
`;
case 'secondary':
return css`
background-color: ${theme.colors.gray[100]};
color: ${theme.colors.gray[900]};
&:hover:not(:disabled) {
background-color: ${theme.colors.gray[200]};
}
`;
case 'outline':
return css`
border: 2px solid ${theme.colors.primary[500]};
color: ${theme.colors.primary[500]};
background-color: transparent;
&:hover:not(:disabled) {
background-color: ${theme.colors.primary[50]};
}
`;
case 'ghost':
return css`
color: ${theme.colors.primary[500]};
background-color: transparent;
&:hover:not(:disabled) {
background-color: ${theme.colors.primary[50]};
}
`;
default:
return css`
background-color: ${theme.colors.primary[500]};
color: white;
&:hover:not(:disabled) {
background-color: ${theme.colors.primary[600]};
}
`;
}
}}
`;
const LoadingSpinner = styled.div`
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
export const Button: React.FC<ButtonProps> = ({
children,
loading,
leftIcon,
rightIcon,
disabled,
...props
}) => {
return (
<StyledButton
disabled={disabled || loading}
{...props}
>
{loading ? (
<LoadingSpinner />
) : (
<>
{leftIcon}
{children}
{rightIcon}
</>
)}
</StyledButton>
);
};
Input Component
// src/components/atoms/Input/Input.tsx
import React, { forwardRef } from 'react';
import styled, { css } from 'styled-components';
import { ComponentProps } from '../../../types';
export interface InputProps extends ComponentProps {
type?: 'text' | 'email' | 'password' | 'number' | 'tel';
placeholder?: string;
value?: string;
error?: string;
label?: string;
required?: boolean;
disabled?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const InputContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing.sm};
`;
const Label = styled.label<{ required?: boolean }>`
font-size: ${({ theme }) => theme.typography.fontSize.sm};
font-weight: ${({ theme }) => theme.typography.fontWeight.medium};
color: ${({ theme }) => theme.colors.gray[700]};
${({ required }) => required && css`
&::after {
content: '*';
color: ${({ theme }) => theme.colors.error};
margin-left: ${({ theme }) => theme.spacing.xs};
}
`}
`;
const InputWrapper = styled.div<{ hasError?: boolean; disabled?: boolean }>`
position: relative;
display: flex;
align-items: center;
${({ hasError, theme }) => hasError && css`
.input-field {
border-color: ${theme.colors.error};
&:focus {
border-color: ${theme.colors.error};
box-shadow: 0 0 0 3px ${theme.colors.error}20;
}
}
`}
${({ disabled }) => disabled && css`
opacity: 0.5;
cursor: not-allowed;
`}
`;
const StyledInput = styled.input`
width: 100%;
padding: ${({ theme }) => theme.spacing.md};
border: 2px solid ${({ theme }) => theme.colors.gray[200]};
border-radius: ${({ theme }) => theme.borderRadius.md};
font-size: ${({ theme }) => theme.typography.fontSize.base};
transition: all 0.2s ease-in-out;
background-color: white;
&:focus {
outline: none;
border-color: ${({ theme }) => theme.colors.primary[500]};
box-shadow: 0 0 0 3px ${({ theme }) => theme.colors.primary[500]}20;
}
&::placeholder {
color: ${({ theme }) => theme.colors.gray[400]};
}
&:disabled {
background-color: ${({ theme }) => theme.colors.gray[50]};
cursor: not-allowed;
}
`;
const IconWrapper = styled.div<{ position: 'left' | 'right' }>`
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
color: ${({ theme }) => theme.colors.gray[400]};
${({ position }) => position === 'left' ? css`
left: ${({ theme }) => theme.spacing.md};
` : css`
right: ${({ theme }) => theme.spacing.md};
`}
${({ position }) => position === 'left' && css`
& + .input-field {
padding-left: ${({ theme }) => theme.spacing['2xl']};
}
`}
${({ position }) => position === 'right' && css`
& ~ .input-field {
padding-right: ${({ theme }) => theme.spacing['2xl']};
}
`}
`;
const ErrorMessage = styled.span`
font-size: ${({ theme }) => theme.typography.fontSize.sm};
color: ${({ theme }) => theme.colors.error};
`;
export const Input = forwardRef<HTMLInputElement, InputProps>(({
label,
error,
leftIcon,
rightIcon,
required,
disabled,
className,
...props
}, ref) => {
return (
<InputContainer className={className}>
{label && (
<Label required={required}>
{label}
</Label>
)}
<InputWrapper hasError={!!error} disabled={disabled}>
{leftIcon && (
<IconWrapper position="left">
{leftIcon}
</IconWrapper>
)}
<StyledInput
ref={ref}
className="input-field"
disabled={disabled}
{...props}
/>
{rightIcon && (
<IconWrapper position="right">
{rightIcon}
</IconWrapper>
)}
</InputWrapper>
{error && (
<ErrorMessage>{error}</ErrorMessage>
)}
</InputContainer>
);
});
Molecules (Simple Combinations)
Product Card Component
// src/components/molecules/ProductCard/ProductCard.tsx
import React from 'react';
import styled from 'styled-components';
import { motion } from 'framer-motion';
import { Button } from '../../atoms/Button/Button';
import { Product } from '../../../types';
export interface ProductCardProps {
product: Product;
onAddToCart?: (product: Product) => void;
onViewDetail?: (product: Product) => void;
}
const CardWrapper = styled(motion.div)`
background: white;
border-radius: ${({ theme }) => theme.borderRadius.lg};
box-shadow: ${({ theme }) => theme.shadows.md};
overflow: hidden;
transition: all 0.3s ease;
&:hover {
box-shadow: ${({ theme }) => theme.shadows.xl};
transform: translateY(-2px);
}
`;
const ImageWrapper = styled.div`
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
`;
const ProductImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
${CardWrapper}:hover & {
transform: scale(1.05);
}
`;
const BekasiSellerBadge = styled.div`
position: absolute;
top: ${({ theme }) => theme.spacing.sm};
left: ${({ theme }) => theme.spacing.sm};
background: linear-gradient(135deg, ${({ theme }) => theme.colors.bekasi.green}, ${({ theme }) => theme.colors.bekasi.gold});
color: white;
padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.sm};
border-radius: ${({ theme }) => theme.borderRadius.full};
font-size: ${({ theme }) => theme.typography.fontSize.xs};
font-weight: ${({ theme }) => theme.typography.fontWeight.bold};
`;
const CardContent = styled.div`
padding: ${({ theme }) => theme.spacing.lg};
`;
const ProductTitle = styled.h3`
font-size: ${({ theme }) => theme.typography.fontSize.lg};
font-weight: ${({ theme }) => theme.typography.fontWeight.semibold};
color: ${({ theme }) => theme.colors.gray[900]};
margin-bottom: ${({ theme }) => theme.spacing.sm};
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
`;
const SellerInfo = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing.sm};
margin-bottom: ${({ theme }) => theme.spacing.md};
`;
const SellerName = styled.span`
font-size: ${({ theme }) => theme.typography.fontSize.sm};
color: ${({ theme }) => theme.colors.gray[600]};
`;
const VerifiedBadge = styled.span`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing.xs};
font-size: ${({ theme }) => theme.typography.fontSize.xs};
color: ${({ theme }) => theme.colors.bekasi.green};
font-weight: ${({ theme }) => theme.typography.fontWeight.medium};
`;
const PriceSection = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing.md};
`;
const Price = styled.span`
font-size: ${({ theme }) => theme.typography.fontSize.xl};
font-weight: ${({ theme }) => theme.typography.fontWeight.bold};
color: ${({ theme }) => theme.colors.bekasi.green};
`;
const Rating = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing.xs};
font-size: ${({ theme }) => theme.typography.fontSize.sm};
color: ${({ theme }) => theme.colors.gray[600]};
`;
const ActionButtons = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing.sm};
`;
const StarIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
const VerifiedIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
const formatPrice = (price: number): string => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(price);
};
export const ProductCard: React.FC<ProductCardProps> = ({
product,
onAddToCart,
onViewDetail,
}) => {
const handleAddToCart = () => {
onAddToCart?.(product);
};
const handleViewDetail = () => {
onViewDetail?.(product);
};
return (
<CardWrapper
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
transition={{ duration: 0.3 }}
>
<ImageWrapper>
<ProductImage src={product.image} alt={product.name} />
{product.seller.bekasiSeller && (
<BekasiSellerBadge>
Seller Bekasi
</BekasiSellerBadge>
)}
</ImageWrapper>
<CardContent>
<ProductTitle>{product.name}</ProductTitle>
<SellerInfo>
<SellerName>{product.seller.name}</SellerName>
{product.seller.verified && (
<VerifiedBadge>
<VerifiedIcon />
Verified
</VerifiedBadge>
)}
</SellerInfo>
<PriceSection>
<Price>{formatPrice(product.price)}</Price>
<Rating>
<StarIcon />
{product.rating.toFixed(1)}
</Rating>
</PriceSection>
<ActionButtons>
<Button
variant="outline"
size="sm"
onClick={handleViewDetail}
fullWidth
>
Lihat Detail
</Button>
<Button
variant="bekasi"
size="sm"
onClick={handleAddToCart}
fullWidth
>
+ Keranjang
</Button>
</ActionButtons>
</CardContent>
</CardWrapper>
);
};
Search Bar Component
// src/components/molecules/SearchBar/SearchBar.tsx
import React, { useState } from 'react';
import styled from 'styled-components';
import { Input } from '../../atoms/Input/Input';
import { Button } from '../../atoms/Button/Button';
export interface SearchBarProps {
placeholder?: string;
onSearch?: (query: string) => void;
onFilterToggle?: () => void;
showFilter?: boolean;
}
const SearchContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing.sm};
align-items: flex-end;
`;
const SearchInputWrapper = styled.div`
flex: 1;
`;
const SearchIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
);
const FilterIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polygon points="22,3 2,3 10,12.46 10,19 14,21 14,12.46"></polygon>
</svg>
);
export const SearchBar: React.FC<SearchBarProps> = ({
placeholder = "Cari produk, toko, atau kategori...",
onSearch,
onFilterToggle,
showFilter = true,
}) => {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch?.(query);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
return (
<form onSubmit={handleSubmit}>
<SearchContainer>
<SearchInputWrapper>
<Input
type="text"
placeholder={placeholder}
value={query}
onChange={handleInputChange}
rightIcon={<SearchIcon />}
/>
</SearchInputWrapper>
<Button type="submit" variant="bekasi">
Cari
</Button>
{showFilter && (
<Button
type="button"
variant="outline"
onClick={onFilterToggle}
leftIcon={<FilterIcon />}
>
Filter
</Button>
)}
</SearchContainer>
</form>
);
};
Organisms (Complex UI Sections)
Product Grid Component
// src/components/organisms/ProductGrid/ProductGrid.tsx
import React from 'react';
import styled from 'styled-components';
import { ProductCard } from '../../molecules/ProductCard/ProductCard';
import { Product } from '../../../types';
export interface ProductGridProps {
products: Product[];
loading?: boolean;
onProductSelect?: (product: Product) => void;
onAddToCart?: (product: Product) => void;
}
const GridContainer = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: ${({ theme }) => theme.spacing.xl};
padding: ${({ theme }) => theme.spacing.lg} 0;
`;
const LoadingGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: ${({ theme }) => theme.spacing.xl};
padding: ${({ theme }) => theme.spacing.lg} 0;
`;
const LoadingCard = styled.div`
background: white;
border-radius: ${({ theme }) => theme.borderRadius.lg};
box-shadow: ${({ theme }) => theme.shadows.md};
overflow: hidden;
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
`;
const EmptyState = styled.div`
grid-column: 1 / -1;
text-align: center;
padding: ${({ theme }) => theme.spacing['2xl']};
color: ${({ theme }) => theme.colors.gray[500]};
`;
const SkeletonImage = styled.div`
width: 100%;
height: 200px;
background: #f0f0f0;
`;
const SkeletonContent = styled.div`
padding: ${({ theme }) => theme.spacing.lg};
.skeleton-title {
height: 20px;
background: #f0f0f0;
border-radius: 4px;
margin-bottom: ${({ theme }) => theme.spacing.sm};
}
.skeleton-seller {
height: 16px;
background: #f0f0f0;
border-radius: 4px;
width: 60%;
margin-bottom: ${({ theme }) => theme.spacing.md};
}
.skeleton-price {
height: 24px;
background: #f0f0f0;
border-radius: 4px;
width: 40%;
margin-bottom: ${({ theme }) => theme.spacing.md};
}
.skeleton-buttons {
display: flex;
gap: ${({ theme }) => theme.spacing.sm};
.skeleton-button {
height: 36px;
background: #f0f0f0;
border-radius: 4px;
flex: 1;
}
}
`;
const LoadingCardSkeleton: React.FC = () => (
<LoadingCard>
<SkeletonImage className="skeleton" />
<SkeletonContent>
<div className="skeleton-title skeleton" />
<div className="skeleton-seller skeleton" />
<div className="skeleton-price skeleton" />
<div className="skeleton-buttons">
<div className="skeleton-button skeleton" />
<div className="skeleton-button skeleton" />
</div>
</SkeletonContent>
</LoadingCard>
);
export const ProductGrid: React.FC<ProductGridProps> = ({
products,
loading,
onProductSelect,
onAddToCart,
}) => {
if (loading) {
return (
<LoadingGrid>
{Array.from({ length: 8 }, (_, index) => (
<LoadingCardSkeleton key={index} />
))}
</LoadingGrid>
);
}
if (products.length === 0) {
return (
<EmptyState>
<h3>Tidak ada produk ditemukan</h3>
<p>Coba ubah filter pencarian atau kata kunci</p>
</EmptyState>
);
}
return (
<GridContainer>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onViewDetail={onProductSelect}
onAddToCart={onAddToCart}
/>
))}
</GridContainer>
);
};
๐ช Custom Hooks
useLocalStorage Hook
// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
useDebounce Hook
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
useApi Hook
// src/hooks/useApi.ts
import { useState, useEffect } from 'react';
interface ApiState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export function useApi<T>(
apiFunction: () => Promise<T>,
dependencies: any[] = []
): ApiState<T> & { refetch: () => void } {
const [state, setState] = useState<ApiState<T>>({
data: null,
loading: true,
error: null,
});
const fetchData = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }));
const result = await apiFunction();
setState({ data: result, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error.message : 'An error occurred',
});
}
};
useEffect(() => {
fetchData();
}, dependencies);
return {
...state,
refetch: fetchData,
};
}
๐ฑ Responsive Design
Responsive Grid System
// src/components/organisms/ResponsiveGrid/ResponsiveGrid.tsx
import styled, { css } from 'styled-components';
interface GridProps {
columns?: {
mobile?: number;
tablet?: number;
desktop?: number;
};
gap?: string;
}
export const ResponsiveGrid = styled.div<GridProps>`
display: grid;
gap: ${({ gap = '1rem' }) => gap};
/* Mobile first approach */
grid-template-columns: repeat(${({ columns }) => columns?.mobile || 1}, 1fr);
/* Tablet */
@media (min-width: 768px) {
grid-template-columns: repeat(${({ columns }) => columns?.tablet || 2}, 1fr);
}
/* Desktop */
@media (min-width: 1024px) {
grid-template-columns: repeat(${({ columns }) => columns?.desktop || 3}, 1fr);
}
`;
// Usage example
const ExampleGrid = () => (
<ResponsiveGrid
columns={{ mobile: 1, tablet: 2, desktop: 4 }}
gap="2rem"
>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</ResponsiveGrid>
);
Breakpoint Utilities
// src/utils/breakpoints.ts
export const breakpoints = {
mobile: '0px',
tablet: '768px',
desktop: '1024px',
wide: '1280px',
} as const;
export const mediaQuery = {
mobile: `@media (min-width: ${breakpoints.mobile})`,
tablet: `@media (min-width: ${breakpoints.tablet})`,
desktop: `@media (min-width: ${breakpoints.desktop})`,
wide: `@media (min-width: ${breakpoints.wide})`,
} as const;
// Usage in styled components
const ResponsiveComponent = styled.div`
padding: 1rem;
${mediaQuery.tablet} {
padding: 2rem;
}
${mediaQuery.desktop} {
padding: 3rem;
}
`;
๐งช Testing Components
Component Testing dengan Jest & React Testing Library
// src/components/atoms/Button/Button.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { Button } from './Button';
import { theme } from '../../../styles/theme';
const renderWithTheme = (component: React.ReactElement) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
describe('Button Component', () => {
test('renders button with text', () => {
renderWithTheme(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
renderWithTheme(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('shows loading state', () => {
renderWithTheme(<Button loading>Loading...</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
test('applies bekasi variant styles', () => {
renderWithTheme(<Button variant="bekasi">Bekasi Button</Button>);
const button = screen.getByRole('button');
expect(button).toHaveStyle('background: linear-gradient(135deg, #22c55e, #fbbf24)');
});
});
Storybook Setup
// .storybook/main.js
module.exports = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-controls',
'@storybook/addon-viewport',
],
};
// src/components/atoms/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Atoms/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'bekasi', 'outline', 'ghost'],
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg'],
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
children: 'Primary Button',
variant: 'primary',
},
};
export const Bekasi: Story = {
args: {
children: 'Bekasi Style',
variant: 'bekasi',
},
};
export const Loading: Story = {
args: {
children: 'Loading Button',
loading: true,
},
};
export const WithIcons: Story = {
args: {
children: 'Add to Cart',
leftIcon: <span>๐</span>,
variant: 'bekasi',
},
};
๐ Performance Optimization
Lazy Loading Components
// src/components/LazyProductGrid.tsx
import React, { Suspense } from 'react';
import { ProductGridSkeleton } from './ProductGridSkeleton';
const ProductGrid = React.lazy(() =>
import('./organisms/ProductGrid/ProductGrid').then(module => ({
default: module.ProductGrid
}))
);
export const LazyProductGrid: React.FC<any> = (props) => (
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid {...props} />
</Suspense>
);
Memoization
// src/components/molecules/ProductCard/ProductCard.tsx
import React, { memo } from 'react';
export const ProductCard = memo<ProductCardProps>(({
product,
onAddToCart,
onViewDetail,
}) => {
// Component implementation
}, (prevProps, nextProps) => {
// Custom comparison function
return (
prevProps.product.id === nextProps.product.id &&
prevProps.product.price === nextProps.product.price &&
prevProps.product.rating === nextProps.product.rating
);
});
๐ฆ Build & Deployment
Component Library Build
// package.json
{
"name": "bekasi-ui-components",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc && rollup -c",
"build-storybook": "build-storybook",
"publish": "npm publish"
}
}
Rollup Configuration
// rollup.config.js
import typescript from '@rollup/plugin-typescript';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'cjs',
sourcemap: true,
},
{
file: 'dist/index.esm.js',
format: 'esm',
sourcemap: true,
},
],
plugins: [
peerDepsExternal(),
resolve(),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
}),
],
};
Selamat! Anda telah membangun component library React yang modern dan scalable. Library ini dapat digunakan across multiple projects di Bekasi untuk konsistensi UI dan faster development.
Tutorial selanjutnya: "Advanced React Patterns: Compound Components & Render Props" - coming soon!
#React #ComponentLibrary #UIUXDesign #TypeScript #StyledComponents #BekasiDev