Refactoring YappApp: A Journey from Spaghetti to Structure
When I first built YappApp, a social platform for daily discussion prompts, I was focused on getting features working. Authentication? Threw it in the components. State management? Sprinkled throughout the app. CSS? Copy-pasted wherever needed. It worked, but as the application grew, so did the technical debt.
Six months and countless "quick fixes" later, I realized I had created what developers fondly call "spaghetti code." Here's how I refactored it into something both maintainable and scalable.
The Initial State: A Tangled Web
Let's look at what I started with. Here's a snippet from an early version of my post creation component:
const CreatePost = () => {
const [text, setText] = useState('')
const [error, setError] = useState('')
const [user, setUser] = useState(null)
const [token, setToken] = useState('')
const navigate = useNavigate()
useEffect(() => {
// Authentication logic mixed with component logic
const token = localStorage.getItem('token')
if (!token) {
navigate('/')
return
}
// Decode token and set user
setToken(token)
setUser(JSON.parse(atob(token.split('.')[1])))
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ text, user: user.id }),
})
// More directly coupled API logic...
} catch (err) {
setError(err.message)
}
}
// ...rest of the component
}
The issues were clear:
- Authentication logic mixed with component logic
- Direct API calls within components
- Duplicated code across similar components
- No separation of concerns
The Refactoring Process
Step 1: Centralizing Authentication
First, I created a dedicated AuthContext to handle all authentication-related logic:
// src/contexts/AuthContext.jsx
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const currentUser = authService.getUser()
if (currentUser) {
setUser(currentUser)
}
setIsLoading(false)
}, [])
const value = {
user,
signin,
signup,
signout,
updateUser,
isLoading,
isAuthenticated: !!user,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
This immediately cleaned up all components that needed authentication, reducing them to:
const { user } = useAuth()
Step 2: Creating a Service Layer
Next, I extracted all API calls into dedicated service files:
// src/services/postService.js
export const createPost = async (postData) => {
try {
const token = localStorage.getItem('token')
const response = await fetch(`${BASE_URL}/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(postData),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to create post')
}
return data
} catch (error) {
throw error
}
}
Step 3: Component Reorganization
I then refactored components to follow a clear, single-responsibility principle. The CreatePost component became:
const CreatePost = () => {
const [text, setText] = useState('')
const [error, setError] = useState('')
const { user } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
try {
await postService.createPost({
owner: user._id,
prompt: promptId,
text: text.trim(),
})
navigate(`/prompt/${promptId}`)
} catch (err) {
setError(err.message)
}
}
return <div className="create-post-container">{/* Clean, focused JSX */}</div>
}
Step 4: Implementing Protected Routes
To handle authentication-based routing, I created a ProtectedRoute component:
const ProtectedRoute = ({ children }) => {
const { user } = useAuth()
if (!user) {
return <Navigate to="/" replace />
}
return (
<>
<NavBar />
{children}
<Footer />
</>
)
}
The Results
After refactoring, the benefits were immediate:
- Reduced Code Duplication: Common functionality was centralized in services and contexts
- Improved Testing: With separated concerns, unit testing became straightforward
- Better Error Handling: Consistent error handling across the application
- Easier Maintenance: New team members could understand and modify code more easily
- Enhanced Performance: Reduced unnecessary re-renders and better state management
Key Lessons Learned
- Start with Structure: Even if you're prototyping, basic architecture decisions save time later
- Separate Concerns: Keep components focused on their primary responsibility
- Create Abstractions Early: Identify common patterns and create reusable solutions
- Document Decisions: Keep track of why you made certain architectural choices
Looking Forward
The refactoring process never really ends. As new features are added and requirements change, maintaining clean code is an ongoing process. However, with proper structure in place, these changes become evolutionary rather than revolutionary.
Is your code base feeling tangled? Start small. Pick one component or feature and refactor it following these principles. You'll be surprised how quickly small improvements add up to significant changes in maintainability and developer experience.
Want to see the complete refactored code? Check out the YappApp on GitHub.
Remember, good code isn't about perfection - it's about being better than yesterday.