Overview
DevJobs uses a custom hooks-based state management approach instead of traditional solutions like Redux or Context API. State is synchronized with URL search parameters, making it:
Shareable : Users can share filtered search results via URLs
Bookmarkable : Search states can be saved as bookmarks
History-aware : Browser back/forward buttons work naturally
Deep-linkable : Direct access to any application state
State Management Philosophy
URL as Single Source of Truth Search parameters in the URL drive application state
Custom Hooks for Logic Reusable hooks encapsulate state and side effects
Local State Only No global state management - components own their state
Declarative Updates State changes automatically sync to URL and trigger effects
Custom Hooks
DevJobs includes three primary custom hooks:
1. useFilters
Manages filter state, pagination, and API requests for job listings.
Location : src/hooks/useFilters.js:6
import { useEffect , useState } from 'react'
import { useSearchParams } from 'react-router-dom'
const RESULTS_PER_PAGE = 4
export const useFilters = () => {
const [ searchParams , setSearchParams ] = useSearchParams ()
// Initialize filters from URL
const [ filters , setFilters ] = useState (() => ({
technology: searchParams . get ( 'technology' ) || '' ,
location: searchParams . get ( 'type' ) || '' ,
experienceLevel: searchParams . get ( 'level' ) || ''
}))
const [ textToFilter , setTextToFilter ] = useState (
() => searchParams . get ( 'text' ) || ''
)
const [ currentPage , setCurrentPage ] = useState (() => {
const page = searchParams . get ( 'page' ) || '1'
const pageNumber = Number ( page )
return isNaN ( pageNumber ) || pageNumber < 1 ? 1 : pageNumber
})
// API state
const [ jobs , setJobs ] = useState ([])
const [ total , setTotal ] = useState ( 0 )
const [ loading , setLoading ] = useState ( true )
// ... (API fetching and URL sync logic)
return {
jobs ,
loading ,
total ,
totalPages ,
currentPage ,
textToFilter ,
handlePageChange ,
handleSearch ,
handleTextFilter
}
}
2. useRouter
Provides navigation utilities wrapping React Router hooks.
Location : src/hooks/useRouter.js:3
import { useNavigate , useLocation } from 'react-router-dom'
export function useRouter () {
const navigate = useNavigate ()
const location = useLocation ()
function navigateTo ( path ) {
navigate ( path )
}
return {
currentPath: location . pathname ,
navigateTo
}
}
Manages search form state with debounced text input and filter synchronization.
Location : src/hooks/useSearchForm.js:4
import { useEffect , useRef , useState } from 'react'
import { useSearchParams } from 'react-router-dom'
export const useSearchForm = ({
onTextFilter ,
onSearch ,
idTechnology ,
idLocation ,
idExperienceLevel ,
idText
}) => {
const [ searchParams ] = useSearchParams ()
const timeoutId = useRef ( null )
// Refs for form elements
const inputRef = useRef ( null )
const selectRefTechnology = useRef ( null )
const selectRefLocation = useRef ( null )
const selectRefExperienceLevel = useRef ( null )
const [ searchText , setSearchText ] = useState ( '' )
// ... (handlers and URL sync)
return {
searchText ,
handleSubmit ,
handleTextChange ,
handleClearFilters ,
inputRef ,
selectRefTechnology ,
selectRefLocation ,
selectRefExperienceLevel
}
}
URL Synchronization Patterns
Reading from URL
Initialize state from URL parameters using lazy initialization:
const [ filters , setFilters ] = useState (() => ({
technology: searchParams . get ( 'technology' ) || '' ,
location: searchParams . get ( 'type' ) || '' ,
experienceLevel: searchParams . get ( 'level' ) || ''
}))
Using a function in useState ensures URL is only read once on mount, not on every render.
Writing to URL
Synchronize state changes back to URL using useEffect:
useEffect (() => {
setSearchParams (() => {
const params = new URLSearchParams ()
// Only add params with values
if ( textToFilter ) params . set ( 'text' , textToFilter )
if ( filters . technology ) params . set ( 'technology' , filters . technology )
if ( filters . location ) params . set ( 'type' , filters . location )
if ( filters . experienceLevel ) params . set ( 'level' , filters . experienceLevel )
if ( currentPage > 1 ) params . set ( 'page' , currentPage )
return params
})
}, [ filters , textToFilter , currentPage , setSearchParams ])
Only include parameters with values to keep URLs clean. Empty filters are omitted.
Bidirectional Sync Flow
Filter State Management
The useFilters hook demonstrates a complete state management pattern.
State Structure
// Filter criteria
const [ filters , setFilters ] = useState ({
technology: '' , // e.g., 'react', 'vue'
location: '' , // Job type: 'remote', 'onsite'
experienceLevel: '' // e.g., 'junior', 'senior'
})
// Text search
const [ textToFilter , setTextToFilter ] = useState ( '' )
// Pagination
const [ currentPage , setCurrentPage ] = useState ( 1 )
// API response
const [ jobs , setJobs ] = useState ([])
const [ total , setTotal ] = useState ( 0 )
const [ loading , setLoading ] = useState ( true )
API Integration
State changes trigger API requests via useEffect:
useEffect (() => {
async function fetchJobs () {
try {
setLoading ( true )
// Build query parameters from state
const params = new URLSearchParams ()
if ( textToFilter ) params . append ( 'text' , textToFilter )
if ( filters . technology ) params . append ( 'technology' , filters . technology )
if ( filters . location ) params . append ( 'type' , filters . location )
if ( filters . experienceLevel ) params . append ( 'level' , filters . experienceLevel )
// Add pagination
const offset = ( currentPage - 1 ) * RESULTS_PER_PAGE
params . append ( 'limit' , RESULTS_PER_PAGE )
params . append ( 'offset' , offset )
// Fetch jobs
const response = await fetch (
`https://jscamp-api.vercel.app/api/jobs? ${ params . toString () } `
)
const json = await response . json ()
setJobs ( json . data )
setTotal ( json . total )
} catch ( error ) {
console . error ( 'Error fetching jobs:' , error )
} finally {
setLoading ( false )
}
}
fetchJobs ()
}, [ filters , textToFilter , currentPage ])
The effect runs whenever filters, text, or page changes, keeping results synchronized with state.
State Update Handlers
const handleSearch = ( filters ) => {
setFilters ( filters )
setCurrentPage ( 1 ) // Reset to first page on filter change
}
const handleTextFilter = ( newTextToFilter ) => {
setTextToFilter ( newTextToFilter )
setCurrentPage ( 1 ) // Reset to first page on search
}
const handlePageChange = ( page ) => {
setCurrentPage ( page )
}
Always reset currentPage to 1 when filters change to avoid showing empty results.
Usage in Components
import { useFilters } from '@/hooks/useFilters.js'
function SearchPage () {
const {
jobs ,
loading ,
total ,
totalPages ,
currentPage ,
textToFilter ,
handlePageChange ,
handleSearch ,
handleTextFilter
} = useFilters ()
return (
< main >
< SearchFormSection
initialTextInput = { textToFilter }
onSearch = { handleSearch }
onTextFilter = { handleTextFilter }
/>
{ loading ? (
< JobCardSkeleton count = { 5 } />
) : (
< JobListings jobs = { jobs } />
) }
< Pagination
currentPage = { currentPage }
totalPages = { totalPages }
onPageChange = { handlePageChange }
/>
</ main >
)
}
The useSearchForm hook manages complex form interactions.
Debounced Text Input
Text input is debounced to reduce API calls:
const handleTextChange = ( event ) => {
const text = event . target . value
setSearchText ( text )
// Cancel previous timeout
if ( timeoutId . current ) {
clearTimeout ( timeoutId . current )
}
// Debounce: wait 500ms before filtering
timeoutId . current = setTimeout (() => {
onTextFilter ( text )
}, 500 )
}
500ms debounce provides responsive feedback while limiting API requests to one per half-second.
const handleSubmit = ( event ) => {
event . preventDefault ()
const formData = new FormData ( event . currentTarget )
// Don't submit on text input change
if ( event . target . name === idText ) {
return
}
const filters = {
technology: formData . get ( idTechnology ),
location: formData . get ( idLocation ),
experienceLevel: formData . get ( idExperienceLevel )
}
onSearch ( filters )
}
Form fields are synchronized when URL changes (e.g., browser back button):
useEffect (() => {
const tech = searchParams . get ( 'technology' ) || ''
const location = searchParams . get ( 'type' ) || ''
const level = searchParams . get ( 'level' ) || ''
if ( selectRefTechnology . current ) {
selectRefTechnology . current . value = tech
}
if ( selectRefLocation . current ) {
selectRefLocation . current . value = location
}
if ( selectRefExperienceLevel . current ) {
selectRefExperienceLevel . current . value = level
}
}, [ searchParams ])
This ensures form state stays in sync with URL when users navigate via browser controls.
Clear Filters
const handleClearFilters = ( event ) => {
event . preventDefault ()
// Clear state
onTextFilter ( '' )
onSearch ({
technology: '' ,
location: '' ,
experienceLevel: ''
})
// Clear form fields
inputRef . current . value = ''
selectRefTechnology . current . value = ''
selectRefLocation . current . value = ''
selectRefExperienceLevel . current . value = ''
setSearchText ( '' )
}
State Flow Example
Here’s a complete flow when a user filters jobs:
User selects technology filter
User selects “React” from technology dropdown
Form submission
handleSubmit extracts form data and calls onSearch({ technology: 'react' })
State update
handleSearch in useFilters calls setFilters({ technology: 'react' }) and setCurrentPage(1)
URL synchronization
useEffect in useFilters detects filter change and updates URL to /search?technology=react
API request
Another useEffect detects filter change and fetches jobs with new parameters
UI update
Component re-renders with new jobs, loading state updates accordingly
Best Practices
Use lazy initialization for URL params
Read URL parameters only once on mount: const [ state , setState ] = useState (() =>
searchParams . get ( 'param' ) || defaultValue
)
Reset pagination on filter changes
Reduce API calls by debouncing text search: setTimeout (() => onTextFilter ( text ), 500 )
Clear timeouts when component unmounts: useEffect (() => {
return () => {
if ( timeoutId . current ) {
clearTimeout ( timeoutId . current )
}
}
}, [])
Only sync non-empty params to URL
Keep URLs clean by excluding empty values: if ( value ) params . set ( 'key' , value )
Advantages of This Approach
No Global State Components remain independent and easier to test
Shareable URLs Any application state can be shared via URL
Browser Integration Back/forward buttons work naturally without extra code
Simple Mental Model URL is the single source of truth - easy to reason about
Better Performance No unnecessary re-renders from global context changes
Persistent State State survives page refreshes via URL parameters
When to Use This Pattern
This URL-based state management pattern works best for:
Search and filter interfaces
Paginated lists
Multi-step forms
Dashboard configurations
Any state that should be shareable or bookmarkable
Don’t store sensitive data in URL parameters - they’re visible in browser history and logs.
Next Steps
Architecture Overview Understand the overall application architecture
Routing Learn about React Router setup and navigation