Skeleton Screens
Skeleton Screens
Skeleton screens show a placeholder that resembles the final content, improving perceived performance.
Basic Skeleton Component
// components/Skeleton.jsx
function Skeleton({ width, height, borderRadius = '4px', className = '' }) {
return (
<div
className={`skeleton ${className}`}
style={{
width,
height,
borderRadius,
}}
/>
);
}
// Card skeleton
function CardSkeleton() {
return (
<div className="card-skeleton">
<Skeleton width="100%" height="200px" borderRadius="8px 8px 0 0" />
<div className="card-skeleton-body">
<Skeleton width="70%" height="24px" />
<Skeleton width="100%" height="16px" />
<Skeleton width="80%" height="16px" />
</div>
</div>
);
}
// List skeleton
function ListSkeleton({ count = 5 }) {
return (
<ul className="list-skeleton">
{Array.from({ length: count }).map((_, i) => (
<li key={i} className="list-skeleton-item">
<Skeleton width="48px" height="48px" borderRadius="50%" />
<div className="list-skeleton-content">
<Skeleton width="60%" height="18px" />
<Skeleton width="40%" height="14px" />
</div>
</li>
))}
</ul>
);
}
CSS Animation
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-text {
height: 1em;
border-radius: 4px;
}
.skeleton-circle {
border-radius: 50%;
}
Usage Pattern
function ProductPage({ productId }) {
const { data: product, isLoading } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
if (isLoading) {
return <ProductSkeleton />;
}
return (
<div className="product">
<img src={product.image} alt={product.name} />
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
Benefits
- Reduces perceived loading time
- Provides visual feedback
- Maintains layout stability
- More informative than spinners
Spinners
Spinners
Spinners indicate ongoing activity when the content shape is unknown.
Spinner Component
// components/Spinner.jsx
function Spinner({ size = 'md', color = 'currentColor', className = '' }) {
const sizeMap = {
sm: '16px',
md: '24px',
lg: '32px',
xl: '48px',
};
return (
<svg
className={`spinner ${className}`}
width={sizeMap[size]}
height={sizeMap[size]}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
stroke={color}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="31.42"
strokeDashoffset="10"
/>
</svg>
);
}
// Button with loading state
function Button({ children, loading, disabled, ...props }) {
return (
<button
disabled={disabled || loading}
{...props}
>
{loading ? (
<span className="btn-loading">
<Spinner size="sm" />
<span>Loading...</span>
</span>
) : (
children
)}
</button>
);
}
Full-Page Loading
function FullPageSpinner() {
return (
<div className="full-page-spinner">
<Spinner size="xl" />
<p>Loading...</p>
</div>
);
}
// App-level loading
function App() {
const { isLoading } = useAuth();
if (isLoading) {
return <FullPageSpinner />;
}
return <Router />;
}
Inline Loading
function InlineLoader({ isLoading, children, fallback = null }) {
if (isLoading) {
return fallback || <Spinner size="sm" />;
}
return children;
}
// Usage
<InlineLoader isLoading={saving}>
<Input value={value} onChange={onChange} />
</InlineLoader>
When to Use Spinners
- Button loading states
- Form submissions
- Unknown content shape
- Short loading durations (< 3s)
Optimistic UI
Optimistic UI
Update the UI immediately before the server confirms the change.
Basic Pattern
function TodoList() {
const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: (newTodo) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(r => r.json()),
// Optimistic update
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old) => [
...old,
{ ...newTodo, id: 'temp-' + Date.now() },
]);
return { previousTodos };
},
// Rollback on error
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
showError('Failed to add todo');
},
// Refetch after success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<div>
<button onClick={() => addTodo.mutate({ text: 'New Todo' })}>
Add Todo
</button>
{addTodo.isPending && <span>Saving...</span>}
</div>
);
}
Delete with Optimistic Update
const deleteTodo = useMutation({
mutationFn: (id) =>
fetch(`/api/todos/${id}`, { method: 'DELETE' }),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.filter((todo) => todo.id !== id)
);
return { previousTodos };
},
onError: (err, id, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
showError('Failed to delete todo');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Considerations
Pros:
- Instant feedback
- Feels faster
- Better UX for predictable operations
Cons:
- Requires rollback logic
- Can cause confusion if server fails
- Not suitable for all operations (payments, emails)
Best Practices:
- Use for predictable, reversible actions
- Show saving state
- Handle errors gracefully with rollback
- Never use for critical financial operations
Practice Problems
Create a reusable React component implementing Loading States. Include proper state management and accessibility.
Solution
// Production-ready component with:
// - Proper TypeScript types
// - Accessibility (ARIA)
// - Error boundaries
// - Loading states
// - Memoization where neededWrite unit and integration tests for Loading States using React Testing Library.
Solution
// Test coverage:
// 1. Rendering tests
// 2. Interaction tests
// 3. Edge case tests
// 4. Accessibility testsOptimize Loading States for performance. Consider memoization, code splitting, and bundle size.
Solution
// Optimization techniques:
// 1. React.memo / useMemo / useCallback
// 2. Code splitting with lazy()
// 3. Virtual scrolling for lists
// 4. Image lazy loading
// 5. Bundle analysisQuiz
1. When should you use skeleton screens over spinners?
2. What is optimistic UI?
3. Why should you cancel queries before optimistic updates?
4. When should you NOT use optimistic updates?
Flashcards
Question
What is a skeleton screen?
Click to reveal answer
Answer
A placeholder UI that mimics the final content shape, shown while loading to improve perceived performance.
Question
When should you use a spinner vs skeleton?
Click to reveal answer
Answer
Spinners for unknown content shape or buttons; skeletons when you know the final layout.
Question
What is rollback in optimistic updates?
Click to reveal answer
Answer
Reverting the UI to the previous state when the server operation fails.
Question
What should you do after an optimistic update succeeds?
Click to reveal answer
Answer
Invalidate the query to refetch the actual server data and ensure consistency.
Question
What is Loading States?
Click to reveal answer
Answer
Loading States is a key concept in frontend development.
Revision Notes
Key Takeaways
- 1.Skeleton screens improve perceived performance by showing content shape
- 2.Use spinners for buttons and unknown content shapes
- 3.Optimistic UI provides instant feedback for predictable operations
- 4.Always implement rollback for failed optimistic updates
- 5.Cancel background queries before applying optimistic updates
Interview Tips
- •Explain when to use skeleton screens vs spinners
- •Describe how optimistic updates work with error handling
- •Discuss the trade-offs of optimistic UI
Cheat Sheet
Loading States Cheat Sheet
Skeleton Screen
<div className="skeleton" style={{ width: '100%', height: '20px' }} />
Spinner
<Spinner size="md" />
Optimistic Update
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, old => [...old, newData]);
return { previous };
},
onError: (err, data, context) => {
queryClient.setQueryData(queryKey, context.previous);
},