diff --git a/web/source/css/base.css b/web/source/css/base.css
index 5539c4fc9..98c16acdd 100644
--- a/web/source/css/base.css
+++ b/web/source/css/base.css
@@ -340,4 +340,8 @@ footer {
 	margin: -0.5ex 0 0;
 	object-fit: contain;
 	vertical-align: middle;
+}
+
+.monospace {
+	font-family: monospace;
 }
\ No newline at end of file
diff --git a/web/source/settings-panel/components/form-fields.jsx b/web/source/settings-panel/components/form-fields.jsx
new file mode 100644
index 000000000..a3b19bc17
--- /dev/null
+++ b/web/source/settings-panel/components/form-fields.jsx
@@ -0,0 +1,138 @@
+/*
+	GoToSocial
+	Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+	This program is free software: you can redistribute it and/or modify
+	it under the terms of the GNU Affero General Public License as published by
+	the Free Software Foundation, either version 3 of the License, or
+	(at your option) any later version.
+
+	This program is distributed in the hope that it will be useful,
+	but WITHOUT ANY WARRANTY; without even the implied warranty of
+	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+	GNU Affero General Public License for more details.
+
+	You should have received a copy of the GNU Affero General Public License
+	along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+"use strict";
+
+const React = require("react");
+const Redux = require("react-redux");
+const d = require("dotty");
+
+function eventListeners(dispatch, setter, obj) {
+	return {
+		onTextChange: function (key) {
+			return function (e) {
+				dispatch(setter([key, e.target.value]));
+			};
+		},
+		
+		onCheckChange: function (key) {
+			return function (e) {
+				dispatch(setter([key, e.target.checked]));
+			};
+		},
+		
+		onFileChange: function (key) {
+			return function (e) {
+				let old = d.get(obj, key);
+				if (old != undefined) {
+					URL.revokeObjectURL(old); // no error revoking a non-Object URL as provided by instance
+				}
+				let file = e.target.files[0];
+				let objectURL = URL.createObjectURL(file);
+				dispatch(setter([key, objectURL]));
+				dispatch(setter([`${key}File`, file]));
+			};
+		}
+	};
+}
+
+function get(state, id) {
+	let value;
+	if (id.includes(".")) {
+		value = d.get(state, id);
+	} else {
+		value = state[id];
+	}
+	return value;
+}
+
+// function removeFile(name) {
+// 	return function(e) {
+// 		e.preventDefault();
+// 		dispatch(user.setProfileVal([name, ""]));
+// 		dispatch(user.setProfileVal([`${name}File`, ""]));
+// 	};
+// }
+
+module.exports = {
+	formFields: function formFields(setter, selector) {
+		function FormField({type, id, name, className="", placeHolder="", fileType="", children=null}) {
+			const dispatch = Redux.useDispatch();
+			let state = Redux.useSelector(selector);
+			let {
+				onTextChange,
+				onCheckChange,
+				onFileChange
+			} = eventListeners(dispatch, setter, state);
+
+			let field;
+			let defaultLabel = true;
+			if (type == "text") {
+				field = <input type="text" id={id} value={get(state, id)} placeholder={placeHolder} className={className} onChange={onTextChange(id)}/>;
+			} else if (type == "textarea") {
+				field = <textarea type="text" id={id} value={get(state, id)} placeholder={placeHolder} className={className} onChange={onTextChange(id)}/>;
+			} else if (type == "checkbox") {
+				field = <input type="checkbox" id={id} checked={get(state, id)} className={className} onChange={onCheckChange(id)}/>;
+			} else if (type == "file") {
+				defaultLabel = false;
+				let file = get(state, `${id}File`);
+				field = (
+					<>
+						<label htmlFor={id} className="file-input button">Browse</label>
+						<span>{file ? file.name : "no file selected"}</span>
+						{/* <a onClick={removeFile("header")} href="#">remove</a> */}
+						<input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id)} />
+					</>
+				);
+			} else {
+				defaultLabel = false;
+				field = `unsupported FormField ${type}, this is a developer error`;
+			}
+
+			let label = <label htmlFor={id}>{name}</label>;
+	
+			return (
+				<div className={`form-field ${type}`}>
+					{defaultLabel ? label : null}
+					{field}
+					{children}
+				</div>
+			);
+		}
+
+		return {
+			TextInput: function(props) {
+				return <FormField type="text" {...props} />;
+			},
+	
+			TextArea: function(props) {
+				return <FormField type="textarea" {...props} />;
+			},
+	
+			Checkbox: function(props) {
+				return <FormField type="checkbox" {...props} />;
+			},
+	
+			File: function(props) {
+				return <FormField type="file" {...props} />;
+			},
+		};
+	},
+
+	eventListeners
+};
\ No newline at end of file
diff --git a/web/source/settings-panel/lib/api/index.js b/web/source/settings-panel/lib/api/index.js
index 6a99ed1e9..3a68a8a26 100644
--- a/web/source/settings-panel/lib/api/index.js
+++ b/web/source/settings-panel/lib/api/index.js
@@ -20,23 +20,24 @@
 
 const Promise = require("bluebird");
 const { isPlainObject } = require("is-plain-object");
+const d = require("dotty");
 
 const { APIError } = require("../errors");
 const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions;
 const oauth = require("../../redux/reducers/oauth").actions;
 
-function apiCall(method, route, payload, type="json") {
+function apiCall(method, route, payload, type = "json") {
 	return function (dispatch, getState) {
 		const state = getState();
 		let base = state.oauth.instance;
 		let auth = state.oauth.token;
 		console.log(method, base, route, "auth:", auth != undefined);
-	
+
 		return Promise.try(() => {
 			let url = new URL(base);
 			url.pathname = route;
 			let body;
-	
+
 			let headers = {
 				"Accept": "application/json",
 			};
@@ -50,20 +51,24 @@ function apiCall(method, route, payload, type="json") {
 					Object.entries(payload).forEach(([key, val]) => {
 						if (isPlainObject(val)) {
 							Object.entries(val).forEach(([key2, val2]) => {
-								formData.set(`${key}[${key2}]`, val2);
+								if (val2 != undefined) {
+									formData.set(`${key}[${key2}]`, val2);
+								}
 							});
 						} else {
-							formData.set(key, val);
+							if (val != undefined) {
+								formData.set(key, val);
+							}
 						}
 					});
 					body = formData;
 				}
 			}
-	
+
 			if (auth != undefined) {
 				headers["Authorization"] = auth;
 			}
-	
+
 			return fetch(url.toString(), {
 				method,
 				headers,
@@ -74,7 +79,7 @@ function apiCall(method, route, payload, type="json") {
 			let json = res.json().catch((e) => {
 				throw new APIError(`JSON parsing error: ${e.message}`);
 			});
-	
+
 			return Promise.all([res, json]);
 		}).then(([res, json]) => {
 			if (!res.ok) {
@@ -83,7 +88,7 @@ function apiCall(method, route, payload, type="json") {
 					dispatch(oauth.remove());
 					throw new APIError("Stored OAUTH login was no longer valid, please log in again.");
 				}
-				throw new APIError(json.error, {json});
+				throw new APIError(json.error, { json });
 			} else {
 				return json;
 			}
@@ -91,12 +96,40 @@ function apiCall(method, route, payload, type="json") {
 	};
 }
 
+function getChanges(state, keys) {
+	const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys;
+	const update = {};
+
+	formKeys.forEach((key) => {
+		let value = d.get(state, key);
+		if (value == undefined) {
+			return;
+		}
+		if (renamedKeys[key]) {
+			key = renamedKeys[key];
+		}
+		d.put(update, key, value);
+	});
+
+	fileKeys.forEach((key) => {
+		let file = d.get(state, `${key}File`);
+		if (file != undefined) {
+			if (renamedKeys[key]) {
+				key = renamedKeys[key];
+			}
+			d.put(update, key, file);
+		}
+	});
+
+	return update;
+}
+
 function getCurrentUrl() {
 	return `${window.location.origin}${window.location.pathname}`;
 }
 
 function fetchInstanceWithoutStore(domain) {
-	return function(dispatch, getState) {
+	return function (dispatch, getState) {
 		return Promise.try(() => {
 			let lookup = getState().instances.info[domain];
 			if (lookup != undefined) {
@@ -107,7 +140,7 @@ function fetchInstanceWithoutStore(domain) {
 			// but we don't want to store it there yet
 			// so we mock the API here with our function argument
 			let fakeState = {
-				oauth: {instance: domain}
+				oauth: { instance: domain }
 			};
 
 			return apiCall("GET", "/api/v1/instance")(dispatch, () => fakeState);
@@ -121,7 +154,7 @@ function fetchInstanceWithoutStore(domain) {
 }
 
 function fetchInstance() {
-	return function(dispatch, _getState) {
+	return function (dispatch, _getState) {
 		return Promise.try(() => {
 			return dispatch(apiCall("GET", "/api/v1/instance"));
 		}).then((json) => {
@@ -133,12 +166,15 @@ function fetchInstance() {
 	};
 }
 
+let submoduleArgs = { apiCall, getCurrentUrl, getChanges };
+
 module.exports = {
 	instance: {
 		fetchWithoutStore: fetchInstanceWithoutStore,
 		fetch: fetchInstance
 	},
-	oauth: require("./oauth")({apiCall, getCurrentUrl}),
-	user: require("./user")({apiCall}),
-	apiCall
+	oauth: require("./oauth")(submoduleArgs),
+	user: require("./user")(submoduleArgs),
+	apiCall,
+	getChanges
 };
\ No newline at end of file
diff --git a/web/source/settings-panel/lib/api/user.js b/web/source/settings-panel/lib/api/user.js
index a24bccaff..a2967c449 100644
--- a/web/source/settings-panel/lib/api/user.js
+++ b/web/source/settings-panel/lib/api/user.js
@@ -23,28 +23,13 @@ const d = require("dotty");
 
 const user = require("../../redux/reducers/user").actions;
 
-module.exports = function ({ apiCall }) {
-	function updateCredentials(selector, {formKeys=[], renamedKeys=[], fileKeys=[]}) {
+module.exports = function ({ apiCall, getChanges }) {
+	function updateCredentials(selector, keys) {
 		return function (dispatch, getState) {
 			return Promise.try(() => {
 				const state = selector(getState());
 
-				const update = {};
-
-				formKeys.forEach((key) => {
-					d.put(update, key, d.get(state, key));
-				});
-
-				renamedKeys.forEach(([sendKey, intKey]) => {
-					d.put(update, sendKey, d.get(state, intKey));
-				});
-
-				fileKeys.forEach((key) => {
-					let file = d.get(state, `${key}File`);
-					if (file != undefined) {
-						d.put(update, key, file);
-					}
-				});
+				const update = getChanges(state, keys);
 
 				return dispatch(apiCall("PATCH", "/api/v1/accounts/update_credentials", update, "form"));
 			}).then((account) => {
@@ -63,13 +48,17 @@ module.exports = function ({ apiCall }) {
 				});
 			};
 		},
+
 		updateProfile: function updateProfile() {
-			const formKeys = ["display_name", "locked", "source", "custom_css"];
-			const renamedKeys = [["note", "source.note"]];
+			const formKeys = ["display_name", "locked", "source", "custom_css", "source.note"];
+			const renamedKeys = {
+				"source.note": "note"
+			};
 			const fileKeys = ["header", "avatar"];
 
 			return updateCredentials((state) => state.user.profile, {formKeys, renamedKeys, fileKeys});
 		},
+
 		updateSettings: function updateProfile() {
 			const formKeys = ["source"];
 
diff --git a/web/source/settings-panel/style.css b/web/source/settings-panel/style.css
index 68350239c..4d1d88da2 100644
--- a/web/source/settings-panel/style.css
+++ b/web/source/settings-panel/style.css
@@ -277,7 +277,7 @@ section.with-sidebar > div {
 			flex-direction: column;
 			justify-content: center;
 
-			div.picker {
+			div.form-field {
 				width: 100%;
 				display: flex;
 
diff --git a/web/source/settings-panel/user/profile.js b/web/source/settings-panel/user/profile.js
index f06f0e667..05a1249c5 100644
--- a/web/source/settings-panel/user/profile.js
+++ b/web/source/settings-panel/user/profile.js
@@ -25,9 +25,17 @@ const Redux = require("react-redux");
 const Submit = require("../components/submit");
 
 const api = require("../lib/api");
-const formFields = require("../lib/form-fields");
 const user = require("../redux/reducers/user").actions;
 
+const { formFields } = require("../components/form-fields");
+
+const {
+	TextInput,
+	TextArea,
+	Checkbox,
+	File
+} = formFields(user.setProfileVal, (state) => state.user.profile);
+
 module.exports = function UserProfile() {
 	const dispatch = Redux.useDispatch();
 	const account = Redux.useSelector(state => state.user.profile);
@@ -53,14 +61,6 @@ module.exports = function UserProfile() {
 		});
 	}
 
-	// function removeFile(name) {
-	// 	return function(e) {
-	// 		e.preventDefault();
-	// 		dispatch(user.setProfileVal([name, ""]));
-	// 		dispatch(user.setProfileVal([`${name}File`, ""]));
-	// 	};
-	// }
-
 	return (
 		<div className="user-profile">
 			<h1>Profile</h1>
@@ -79,42 +79,42 @@ module.exports = function UserProfile() {
 				<div className="files">
 					<div>
 						<h3>Header</h3>
-						<div className="picker">
-							<label htmlFor="header" className="file-input button">Browse</label>
-							<span>{account.headerFile ? account.headerFile.name : "no file selected"}</span>
-						</div>
-						{/* <a onClick={removeFile("header")} href="#">remove</a> */}
-						<input className="hidden" id="header" type="file" accept="image/*" onChange={onFileChange("header")} />
+						<File 
+							id="header"
+							fileType="image/*"
+						/>
 					</div>
 					<div>
 						<h3>Avatar</h3>
-						<div className="picker">
-							<label htmlFor="avatar" className="file-input button">Browse</label>
-							<span>{account.avatarFile ? account.avatarFile.name : "no file selected"}</span>
-						</div>
-						{/* <a onClick={removeFile("avatar")} href="#">remove</a> */}
-						<input className="hidden" id="avatar" type="file" accept="image/*" onChange={onFileChange("avatar")} />
+						<File 
+							id="avatar"
+							fileType="image/*"
+						/>
 					</div>
 				</div>
 			</div>
-			<div className="labelinput">
-				<label htmlFor="displayname">Name</label>
-				<input id="displayname" type="text" value={account.display_name} onChange={onTextChange("display_name")} placeholder="A GoToSocial user" />
-			</div>
-			<div className="labelinput">
-				<label htmlFor="bio">Bio</label>
-				<textarea id="bio" value={account.source.note} onChange={onTextChange("source.note")} placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths." />
-			</div>
-			<div className="labelcheckbox">
-				<label htmlFor="locked">Manually approve follow requests?</label>
-				<input id="locked" type="checkbox" checked={account.locked} onChange={onCheckChange("locked")} />
-			</div>
+			<TextInput
+				id="display_name"
+				name="Name"
+				placeHolder="A GoToSocial user"
+			/>
+			<TextArea
+				id="source.note"
+				name="Bio"
+				placeHolder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."
+			/>
+			<Checkbox
+				id="locked"
+				name="Manually approve follow requests? "
+			/>
 			{ !allowCustomCSS ? null :  
-				<div className="labelinput">
-					<label htmlFor="customcss">Custom CSS</label>
-					<textarea className="mono" id="customcss" value={account.custom_css} onChange={onTextChange("custom_css")}/>
-					<a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom CSS (opens in a new tab)</a>
-				</div>
+				<TextArea
+					id="custom_css"
+					name="Custom CSS"
+					className="monospace"
+				>
+					<a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom profile CSS (opens in a new tab)</a>
+				</TextArea>
 			}
 			<Submit onClick={submit} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg} />
 		</div>